From f6b03a469e8ea00b1ed9cbba408cfca7f8374cb5 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Mon, 30 Mar 2026 16:38:52 +0530 Subject: [PATCH 01/79] feat(responses): add use_responses_api_bridge flag for openai/ models with custom api_base MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allows openai/-prefixed models with a custom api_base pointing to a third-party OpenAI-compatible provider to opt-in to the /responses → /chat/completions bridge, rather than forwarding requests natively to /v1/responses (which may not be supported by the provider). Co-Authored-By: Claude Sonnet 4.6 --- litellm/responses/main.py | 4 +- litellm/types/router.py | 3 + .../test_responses_api_bridge_flag.py | 107 ++++++++++++++++++ 3 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 tests/test_litellm/responses/test_responses_api_bridge_flag.py diff --git a/litellm/responses/main.py b/litellm/responses/main.py index c82574278b..1e97951c50 100644 --- a/litellm/responses/main.py +++ b/litellm/responses/main.py @@ -754,6 +754,7 @@ def responses( litellm_logging_obj: LiteLLMLoggingObj = kwargs.get("litellm_logging_obj") # type: ignore litellm_call_id: Optional[str] = kwargs.get("litellm_call_id", None) _is_async = kwargs.pop("aresponses", False) is True + use_responses_api_bridge = kwargs.pop("use_responses_api_bridge", None) # Convert text_format to text parameter if provided text = ResponsesAPIRequestUtils.convert_text_format_to_text_param( @@ -871,6 +872,7 @@ def responses( if _has_file_search_tool(tools) and ( responses_api_provider_config is None + or use_responses_api_bridge is True or not responses_api_provider_config.supports_native_file_search() ): from litellm.responses.file_search.emulated_handler import ( @@ -919,7 +921,7 @@ def responses( **emulated_kwargs, ) - if responses_api_provider_config is None: + if responses_api_provider_config is None or use_responses_api_bridge is True: return litellm_completion_transformation_handler.response_api_handler( model=model, input=input, diff --git a/litellm/types/router.py b/litellm/types/router.py index 4257628e7c..d608f30249 100644 --- a/litellm/types/router.py +++ b/litellm/types/router.py @@ -199,6 +199,7 @@ class GenericLiteLLMParams(CredentialLiteLLMParams, CustomPricingLiteLLMParams): budget_duration: Optional[str] = None use_in_pass_through: Optional[bool] = False use_litellm_proxy: Optional[bool] = False + use_responses_api_bridge: Optional[bool] = None model_config = ConfigDict(extra="allow", arbitrary_types_allowed=True) merge_reasoning_content_in_choices: Optional[bool] = False model_info: Optional[Dict] = None @@ -318,6 +319,8 @@ class LiteLLMParamsTypedDict(TypedDict, total=False): configurable_clientside_auth_params: CONFIGURABLE_CLIENTSIDE_AUTH_PARAMS # for allowing api base switching on finetuned models ## DROP PARAMS ## drop_params: Optional[bool] + ## RESPONSES API BRIDGE ## + use_responses_api_bridge: Optional[bool] ## UNIFIED PROJECT/REGION ## region_name: Optional[str] ## VERTEX AI ## diff --git a/tests/test_litellm/responses/test_responses_api_bridge_flag.py b/tests/test_litellm/responses/test_responses_api_bridge_flag.py new file mode 100644 index 0000000000..51c6ea58f3 --- /dev/null +++ b/tests/test_litellm/responses/test_responses_api_bridge_flag.py @@ -0,0 +1,107 @@ +""" +Tests for the `use_responses_api_bridge` flag that allows openai/ models +with custom api_base to opt-in to the /responses → /chat/completions bridge. +""" + +import os +import sys +from unittest.mock import MagicMock, patch + +sys.path.insert( + 0, os.path.abspath("../../..") +) # Adds the parent directory to the system path + +import litellm + + +class TestUseResponsesApiBridgeFlag: + """Test that use_responses_api_bridge forces the chat completions bridge.""" + + @patch( + "litellm.responses.main.litellm_completion_transformation_handler.response_api_handler" + ) + @patch( + "litellm.responses.main.ProviderConfigManager.get_provider_responses_api_config" + ) + def test_bridge_used_when_flag_is_true(self, mock_get_config, mock_bridge_handler): + """When use_responses_api_bridge=True, the bridge handler should be called + even though the provider (openai) has native responses API support.""" + # Setup: provider config returns a non-None config (native support exists) + mock_get_config.return_value = litellm.OpenAIResponsesAPIConfig() + + mock_bridge_handler.return_value = MagicMock() + + litellm.responses( + model="openai/my-custom-model", + input="Hello", + use_responses_api_bridge=True, + litellm_logging_obj=MagicMock(), + ) + + mock_bridge_handler.assert_called_once() + + @patch("litellm.responses.main.base_llm_http_handler.response_api_handler") + @patch( + "litellm.responses.main.ProviderConfigManager.get_provider_responses_api_config" + ) + def test_native_forwarding_when_flag_absent( + self, mock_get_config, mock_native_handler + ): + """When use_responses_api_bridge is not set, openai/ models should use + native responses API forwarding (existing behavior).""" + mock_get_config.return_value = litellm.OpenAIResponsesAPIConfig() + mock_native_handler.return_value = MagicMock() + + litellm.responses( + model="openai/gpt-4o", + input="Hello", + litellm_logging_obj=MagicMock(), + ) + + mock_native_handler.assert_called_once() + + @patch( + "litellm.responses.main.litellm_completion_transformation_handler.response_api_handler" + ) + @patch( + "litellm.responses.main.ProviderConfigManager.get_provider_responses_api_config" + ) + def test_flag_does_not_leak_into_kwargs(self, mock_get_config, mock_bridge_handler): + """The use_responses_api_bridge flag should be popped from kwargs and not + passed through to the bridge handler.""" + mock_get_config.return_value = litellm.OpenAIResponsesAPIConfig() + mock_bridge_handler.return_value = MagicMock() + + litellm.responses( + model="openai/my-custom-model", + input="Hello", + use_responses_api_bridge=True, + litellm_logging_obj=MagicMock(), + ) + + call_kwargs = mock_bridge_handler.call_args + # The flag should not appear in the kwargs passed to the bridge handler + all_kwargs = call_kwargs.kwargs if call_kwargs.kwargs else {} + assert "use_responses_api_bridge" not in all_kwargs + + @patch( + "litellm.responses.main.litellm_completion_transformation_handler.response_api_handler" + ) + @patch( + "litellm.responses.main.ProviderConfigManager.get_provider_responses_api_config" + ) + def test_bridge_used_when_provider_config_none( + self, mock_get_config, mock_bridge_handler + ): + """When the provider has no native responses API config (returns None), + the bridge should be used regardless of the flag (existing behavior).""" + mock_get_config.return_value = None + mock_bridge_handler.return_value = MagicMock() + + litellm.responses( + model="anthropic/claude-3-haiku", + input="Hello", + litellm_logging_obj=MagicMock(), + ) + + mock_bridge_handler.assert_called_once() From de6fb5895f57bb82c231b33d7db3913056fd6a7a Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Mon, 30 Mar 2026 16:41:58 +0530 Subject: [PATCH 02/79] docs(responses): add use_responses_api_bridge opt-in bridge docs --- docs/my-website/docs/response_api.md | 58 ++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/docs/my-website/docs/response_api.md b/docs/my-website/docs/response_api.md index 0c428000c7..36c9ee1351 100644 --- a/docs/my-website/docs/response_api.md +++ b/docs/my-website/docs/response_api.md @@ -1505,6 +1505,64 @@ curl http://localhost:4000/v1/responses \ +### Opt-in bridge for `openai/` models with custom `api_base` + +If you're using an **OpenAI-compatible third-party provider** (e.g. llama.cpp, vLLM, LM Studio) via `openai/` prefix with a custom `api_base`, LiteLLM will normally forward `/responses` requests directly to that endpoint. If the provider only supports `/chat/completions`, the request will fail. + +Set `use_responses_api_bridge: true` to force the `/responses` → `/chat/completions` bridge for these models. + +#### Python SDK Usage + +```python showLineNumbers title="Force bridge for custom openai/ endpoint" +import litellm + +response = litellm.responses( + model="openai/my-custom-model", + input="Hello!", + api_base="http://localhost:8080", + api_key="fake-key", + use_responses_api_bridge=True, +) + +print(response) +``` + +#### LiteLLM Proxy Usage + +**Setup Config:** + +```yaml showLineNumbers title="config.yaml — bridge for custom openai/ endpoint" +model_list: +- model_name: my-local-model + litellm_params: + model: openai/my-custom-model + api_base: http://localhost:8080/v1 + api_key: fake-key + use_responses_api_bridge: true +``` + +**Start Proxy:** + +```bash showLineNumbers title="Start LiteLLM Proxy" +litellm --config /path/to/config.yaml + +# RUNNING on http://0.0.0.0:4000 +``` + +**Make Request:** + +```bash showLineNumbers title="Request via bridge" +curl http://localhost:4000/v1/responses \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer sk-1234" \ + -d '{ + "model": "my-local-model", + "input": "Hello!" + }' +``` + +This is particularly useful when connecting clients that hardcode the `/responses` endpoint (e.g. OpenAI Codex CLI with `wire_api = "responses"`) to local or third-party OpenAI-compatible providers that only expose `/chat/completions`. + ## Server-side compaction For long-running conversations, you can enable **server-side compaction** so that when the rendered context size crosses a threshold, the server automatically runs compaction in-stream and emits a compaction item—no separate `POST /v1/responses/compact` call is required. From 6b7629ec045e670fa5050aa2dc1381f607035bda Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Wed, 8 Apr 2026 22:00:30 +0530 Subject: [PATCH 03/79] Fix greptile review --- litellm/responses/main.py | 1 + .../test_responses_api_bridge_flag.py | 155 ++++++++++++++++++ 2 files changed, 156 insertions(+) diff --git a/litellm/responses/main.py b/litellm/responses/main.py index 1e97951c50..80bd319569 100644 --- a/litellm/responses/main.py +++ b/litellm/responses/main.py @@ -907,6 +907,7 @@ def responses( "extra_body": extra_body, "timeout": timeout, "custom_llm_provider": custom_llm_provider, + **({"use_responses_api_bridge": True} if use_responses_api_bridge else {}), **{k: v for k, v in kwargs.items() if k not in _internal_skip}, } if _is_async: diff --git a/tests/test_litellm/responses/test_responses_api_bridge_flag.py b/tests/test_litellm/responses/test_responses_api_bridge_flag.py index 51c6ea58f3..727692d55b 100644 --- a/tests/test_litellm/responses/test_responses_api_bridge_flag.py +++ b/tests/test_litellm/responses/test_responses_api_bridge_flag.py @@ -1,6 +1,9 @@ """ Tests for the `use_responses_api_bridge` flag that allows openai/ models with custom api_base to opt-in to the /responses → /chat/completions bridge. + +Includes file_search emulation: the flag must be forwarded on inner aresponses +calls so routed requests do not hit a custom api_base /v1/responses endpoint. """ import os @@ -12,6 +15,7 @@ sys.path.insert( ) # Adds the parent directory to the system path import litellm +from litellm.types.llms.openai import ResponseAPIUsage, ResponsesAPIResponse class TestUseResponsesApiBridgeFlag: @@ -105,3 +109,154 @@ class TestUseResponsesApiBridgeFlag: ) mock_bridge_handler.assert_called_once() + + @patch("litellm.responses.file_search.emulated_handler._call_aresponses") + @patch( + "litellm.responses.main.ProviderConfigManager.get_provider_responses_api_config" + ) + async def test_bridge_flag_forwarded_to_file_search_emulation( + self, mock_get_config, mock_call_aresponses + ): + """When use_responses_api_bridge=True and file_search tool is present, + the flag should be forwarded to the inner aresponses call in the + file_search emulation path.""" + # Setup: provider has native responses API support + mock_get_config.return_value = litellm.OpenAIResponsesAPIConfig() + + # Mock the inner aresponses call to return a valid response + mock_response = ResponsesAPIResponse( + id="resp_123", + model="openai/my-custom-model", + created_at=1234567890, + output=[ + {"type": "message", "content": [{"type": "text", "text": "Answer"}]} + ], + usage=ResponseAPIUsage( + input_tokens=10, output_tokens=5, total_tokens=15 + ), + ) + mock_call_aresponses.return_value = mock_response + + await litellm.aresponses( + model="openai/my-custom-model", + input="Search for information", + tools=[{"type": "file_search"}], + use_responses_api_bridge=True, + litellm_logging_obj=MagicMock(), + ) + + # Verify _call_aresponses was called with use_responses_api_bridge=True + mock_call_aresponses.assert_called_once() + call_kwargs = mock_call_aresponses.call_args.kwargs + assert ( + call_kwargs.get("use_responses_api_bridge") is True + ), "use_responses_api_bridge flag should be forwarded to inner aresponses call" + + @patch( + "litellm.responses.main.litellm_completion_transformation_handler.response_api_handler" + ) + @patch("litellm.vector_stores.main.asearch") + @patch( + "litellm.responses.main.ProviderConfigManager.get_provider_responses_api_config" + ) + async def test_bridge_flag_prevents_native_responses_endpoint_call( + self, mock_get_config, mock_asearch, mock_bridge_handler + ): + """ + Concrete failing scenario: native OpenAI responses config + bridge flag + + file_search → emulation must still route inner calls through the bridge + (chat completions), not POST to api_base /v1/responses. + """ + mock_get_config.return_value = litellm.OpenAIResponsesAPIConfig() + mock_asearch.return_value = [] + + first_response = ResponsesAPIResponse( + id="resp_first", + model="openai/my-local-model", + created_at=1234567890, + output=[ + { + "type": "function_call", + "name": "litellm_file_search", + "call_id": "call_123", + "arguments": '{"queries": ["test query"]}', + } + ], + usage=ResponseAPIUsage( + input_tokens=10, output_tokens=5, total_tokens=15 + ), + ) + second_response = ResponsesAPIResponse( + id="resp_second", + model="openai/my-local-model", + created_at=1234567891, + output=[ + { + "type": "message", + "content": [{"type": "text", "text": "Final answer"}], + } + ], + usage=ResponseAPIUsage( + input_tokens=20, output_tokens=10, total_tokens=30 + ), + ) + mock_bridge_handler.side_effect = [first_response, second_response] + + result = await litellm.aresponses( + model="openai/my-local-model", + input="Search for information", + tools=[ + { + "type": "file_search", + "file_search": {"vector_store_ids": ["vs_123"]}, + } + ], + use_responses_api_bridge=True, + api_base="http://localhost:8080/v1", + litellm_logging_obj=MagicMock(), + ) + + assert mock_bridge_handler.call_count == 2, ( + "Bridge handler should be called twice: initial function-tool call " + "and follow-up with tool results" + ) + for call in mock_bridge_handler.call_args_list: + all_kwargs = call.kwargs if call.kwargs else {} + assert "use_responses_api_bridge" not in all_kwargs + assert result is not None + assert result.id is not None + + @patch("litellm.responses.main.base_llm_http_handler.response_api_handler") + @patch("litellm.vector_stores.main.asearch") + @patch( + "litellm.responses.main.ProviderConfigManager.get_provider_responses_api_config" + ) + async def test_without_bridge_flag_uses_native_endpoint( + self, mock_get_config, mock_asearch, mock_native_handler + ): + """Without the bridge flag, openai/ with native config uses the native handler.""" + mock_get_config.return_value = litellm.OpenAIResponsesAPIConfig() + mock_asearch.return_value = [] + mock_native_handler.return_value = ResponsesAPIResponse( + id="resp_native", + model="openai/gpt-4o", + created_at=1234567890, + output=[ + { + "type": "message", + "content": [{"type": "text", "text": "Native response"}], + } + ], + usage=ResponseAPIUsage( + input_tokens=10, output_tokens=5, total_tokens=15 + ), + ) + + result = await litellm.aresponses( + model="openai/gpt-4o", + input="Hello", + litellm_logging_obj=MagicMock(), + ) + + mock_native_handler.assert_called_once() + assert result is not None From 0a8bf4ec9e7c98b70fffa770d54fe2d9035d9171 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Mon, 13 Apr 2026 18:03:27 +0530 Subject: [PATCH 04/79] feat(responses): rename bridge opt-in to use_chat_completions_api - Add use_chat_completions_api (keep use_responses_api_bridge as deprecated alias) - Support openai/chat_completions/ model prefix for the same behavior - Forward use_chat_completions_api in file_search emulation inner calls - Update response_api.md and extend unit tests Made-with: Cursor --- docs/my-website/docs/response_api.md | 29 +++++++-- litellm/responses/main.py | 44 +++++++++++-- litellm/types/router.py | 6 +- .../test_responses_api_bridge_flag.py | 65 ++++++++++++++++--- 4 files changed, 124 insertions(+), 20 deletions(-) diff --git a/docs/my-website/docs/response_api.md b/docs/my-website/docs/response_api.md index 36c9ee1351..94f5c3e52f 100644 --- a/docs/my-website/docs/response_api.md +++ b/docs/my-website/docs/response_api.md @@ -1509,11 +1509,15 @@ curl http://localhost:4000/v1/responses \ If you're using an **OpenAI-compatible third-party provider** (e.g. llama.cpp, vLLM, LM Studio) via `openai/` prefix with a custom `api_base`, LiteLLM will normally forward `/responses` requests directly to that endpoint. If the provider only supports `/chat/completions`, the request will fail. -Set `use_responses_api_bridge: true` to force the `/responses` → `/chat/completions` bridge for these models. +Use any of these to force the `/responses` → `/chat/completions` bridge: + +1. **`use_chat_completions_api: true`** (recommended) — makes it explicit that LiteLLM will call the provider’s chat-completions API. +2. **`openai/chat_completions/`** — same pattern as `responses/` on chat completions: the model id encodes the routing choice. +3. **`use_responses_api_bridge: true`** — deprecated alias for `use_chat_completions_api` (kept for backward compatibility). #### Python SDK Usage -```python showLineNumbers title="Force bridge for custom openai/ endpoint" +```python showLineNumbers title="Force bridge for custom openai/ endpoint (flag)" import litellm response = litellm.responses( @@ -1521,7 +1525,22 @@ response = litellm.responses( input="Hello!", api_base="http://localhost:8080", api_key="fake-key", - use_responses_api_bridge=True, + use_chat_completions_api=True, +) + +print(response) +``` + +Or encode it in the model id: + +```python showLineNumbers title="Force bridge via openai/chat_completions/ model prefix" +import litellm + +response = litellm.responses( + model="openai/chat_completions/my-custom-model", + input="Hello!", + api_base="http://localhost:8080", + api_key="fake-key", ) print(response) @@ -1538,9 +1557,11 @@ model_list: model: openai/my-custom-model api_base: http://localhost:8080/v1 api_key: fake-key - use_responses_api_bridge: true + use_chat_completions_api: true ``` +Alternatively set `model: openai/chat_completions/my-custom-model` instead of the flag. + **Start Proxy:** ```bash showLineNumbers title="Start LiteLLM Proxy" diff --git a/litellm/responses/main.py b/litellm/responses/main.py index 80bd319569..91e173a7a8 100644 --- a/litellm/responses/main.py +++ b/litellm/responses/main.py @@ -643,6 +643,29 @@ def _apply_prompt_management_to_responses_call( return input, model, custom_llm_provider +# Opt-in via model id (mirrors the `responses/` prefix pattern on chat completions). +_OPENAI_CHAT_COMPLETIONS_RESPONSES_MODEL_PREFIX = "openai/chat_completions/" + + +def _normalize_openai_chat_completions_responses_model(model: str) -> tuple[str, bool]: + """ + Strip `openai/chat_completions/` → `openai/` and return True when the + prefix was applied (same effect as use_chat_completions_api=True). + """ + if not model.startswith(_OPENAI_CHAT_COMPLETIONS_RESPONSES_MODEL_PREFIX): + return model, False + remainder = model[len(_OPENAI_CHAT_COMPLETIONS_RESPONSES_MODEL_PREFIX) :] + if not remainder: + return model, False + return f"openai/{remainder}", True + + +def _pop_use_chat_completions_api_kw(kwargs: Dict[str, Any]) -> bool: + """Pop bridge flags; True if either requests the chat-completions path.""" + use_cc = kwargs.pop("use_chat_completions_api", None) + return bool(use_cc) + + def _resolve_model_provider_for_responses( model: str, custom_llm_provider: Optional[str], @@ -754,7 +777,7 @@ def responses( litellm_logging_obj: LiteLLMLoggingObj = kwargs.get("litellm_logging_obj") # type: ignore litellm_call_id: Optional[str] = kwargs.get("litellm_call_id", None) _is_async = kwargs.pop("aresponses", False) is True - use_responses_api_bridge = kwargs.pop("use_responses_api_bridge", None) + use_chat_completions_api = _pop_use_chat_completions_api_kw(kwargs) # Convert text_format to text parameter if provided text = ResponsesAPIRequestUtils.convert_text_format_to_text_param( @@ -777,6 +800,15 @@ def responses( mock_response=litellm_params.mock_response ) + _stripped_model, _from_chat_completions_prefix = ( + _normalize_openai_chat_completions_responses_model(model) + ) + model = _stripped_model + local_vars["model"] = model + use_chat_completions_api = ( + use_chat_completions_api or _from_chat_completions_prefix + ) + model, custom_llm_provider = _resolve_model_provider_for_responses( model=model, custom_llm_provider=custom_llm_provider, @@ -872,7 +904,7 @@ def responses( if _has_file_search_tool(tools) and ( responses_api_provider_config is None - or use_responses_api_bridge is True + or use_chat_completions_api is True or not responses_api_provider_config.supports_native_file_search() ): from litellm.responses.file_search.emulated_handler import ( @@ -907,7 +939,11 @@ def responses( "extra_body": extra_body, "timeout": timeout, "custom_llm_provider": custom_llm_provider, - **({"use_responses_api_bridge": True} if use_responses_api_bridge else {}), + **( + {"use_chat_completions_api": True} + if use_chat_completions_api + else {} + ), **{k: v for k, v in kwargs.items() if k not in _internal_skip}, } if _is_async: @@ -922,7 +958,7 @@ def responses( **emulated_kwargs, ) - if responses_api_provider_config is None or use_responses_api_bridge is True: + if responses_api_provider_config is None or use_chat_completions_api is True: return litellm_completion_transformation_handler.response_api_handler( model=model, input=input, diff --git a/litellm/types/router.py b/litellm/types/router.py index d608f30249..6f483c883d 100644 --- a/litellm/types/router.py +++ b/litellm/types/router.py @@ -199,7 +199,7 @@ class GenericLiteLLMParams(CredentialLiteLLMParams, CustomPricingLiteLLMParams): budget_duration: Optional[str] = None use_in_pass_through: Optional[bool] = False use_litellm_proxy: Optional[bool] = False - use_responses_api_bridge: Optional[bool] = None + use_chat_completions_api: Optional[bool] = None model_config = ConfigDict(extra="allow", arbitrary_types_allowed=True) merge_reasoning_content_in_choices: Optional[bool] = False model_info: Optional[Dict] = None @@ -319,8 +319,8 @@ class LiteLLMParamsTypedDict(TypedDict, total=False): configurable_clientside_auth_params: CONFIGURABLE_CLIENTSIDE_AUTH_PARAMS # for allowing api base switching on finetuned models ## DROP PARAMS ## drop_params: Optional[bool] - ## RESPONSES API BRIDGE ## - use_responses_api_bridge: Optional[bool] + ## RESPONSES API → CHAT COMPLETIONS BRIDGE ## + use_chat_completions_api: Optional[bool] ## UNIFIED PROJECT/REGION ## region_name: Optional[str] ## VERTEX AI ## diff --git a/tests/test_litellm/responses/test_responses_api_bridge_flag.py b/tests/test_litellm/responses/test_responses_api_bridge_flag.py index 727692d55b..e635e12560 100644 --- a/tests/test_litellm/responses/test_responses_api_bridge_flag.py +++ b/tests/test_litellm/responses/test_responses_api_bridge_flag.py @@ -1,6 +1,7 @@ """ -Tests for the `use_responses_api_bridge` flag that allows openai/ models -with custom api_base to opt-in to the /responses → /chat/completions bridge. +Tests for forcing the /responses → /chat/completions bridge for `openai/` models +(via `use_chat_completions_api`, deprecated `use_responses_api_bridge`, or the +`openai/chat_completions/` model id). Includes file_search emulation: the flag must be forwarded on inner aresponses calls so routed requests do not hit a custom api_base /v1/responses endpoint. @@ -19,7 +20,7 @@ from litellm.types.llms.openai import ResponseAPIUsage, ResponsesAPIResponse class TestUseResponsesApiBridgeFlag: - """Test that use_responses_api_bridge forces the chat completions bridge.""" + """Test that bridge opt-in forces the chat completions path.""" @patch( "litellm.responses.main.litellm_completion_transformation_handler.response_api_handler" @@ -28,8 +29,7 @@ class TestUseResponsesApiBridgeFlag: "litellm.responses.main.ProviderConfigManager.get_provider_responses_api_config" ) def test_bridge_used_when_flag_is_true(self, mock_get_config, mock_bridge_handler): - """When use_responses_api_bridge=True, the bridge handler should be called - even though the provider (openai) has native responses API support.""" + """When use_responses_api_bridge=True (deprecated alias), the bridge runs.""" # Setup: provider config returns a non-None config (native support exists) mock_get_config.return_value = litellm.OpenAIResponsesAPIConfig() @@ -44,6 +44,51 @@ class TestUseResponsesApiBridgeFlag: mock_bridge_handler.assert_called_once() + @patch( + "litellm.responses.main.litellm_completion_transformation_handler.response_api_handler" + ) + @patch( + "litellm.responses.main.ProviderConfigManager.get_provider_responses_api_config" + ) + def test_bridge_used_when_use_chat_completions_api_true( + self, mock_get_config, mock_bridge_handler + ): + """When use_chat_completions_api=True, the bridge handler should be called.""" + mock_get_config.return_value = litellm.OpenAIResponsesAPIConfig() + mock_bridge_handler.return_value = MagicMock() + + litellm.responses( + model="openai/my-custom-model", + input="Hello", + use_chat_completions_api=True, + litellm_logging_obj=MagicMock(), + ) + + mock_bridge_handler.assert_called_once() + + @patch( + "litellm.responses.main.litellm_completion_transformation_handler.response_api_handler" + ) + @patch( + "litellm.responses.main.ProviderConfigManager.get_provider_responses_api_config" + ) + def test_bridge_used_when_model_uses_chat_completions_prefix( + self, mock_get_config, mock_bridge_handler + ): + """`openai/chat_completions/` normalizes to `openai/` and uses the bridge.""" + mock_get_config.return_value = litellm.OpenAIResponsesAPIConfig() + mock_bridge_handler.return_value = MagicMock() + + litellm.responses( + model="openai/chat_completions/my-custom-model", + input="Hello", + litellm_logging_obj=MagicMock(), + ) + + mock_bridge_handler.assert_called_once() + # Model string is provider-normalized after resolution; prefix only forces the bridge. + assert mock_bridge_handler.call_args.kwargs["model"].endswith("my-custom-model") + @patch("litellm.responses.main.base_llm_http_handler.response_api_handler") @patch( "litellm.responses.main.ProviderConfigManager.get_provider_responses_api_config" @@ -84,9 +129,10 @@ class TestUseResponsesApiBridgeFlag: ) call_kwargs = mock_bridge_handler.call_args - # The flag should not appear in the kwargs passed to the bridge handler + # Bridge flags should not appear in the kwargs passed to the bridge handler all_kwargs = call_kwargs.kwargs if call_kwargs.kwargs else {} assert "use_responses_api_bridge" not in all_kwargs + assert "use_chat_completions_api" not in all_kwargs @patch( "litellm.responses.main.litellm_completion_transformation_handler.response_api_handler" @@ -145,12 +191,12 @@ class TestUseResponsesApiBridgeFlag: litellm_logging_obj=MagicMock(), ) - # Verify _call_aresponses was called with use_responses_api_bridge=True + # Verify _call_aresponses was called with use_chat_completions_api=True mock_call_aresponses.assert_called_once() call_kwargs = mock_call_aresponses.call_args.kwargs assert ( - call_kwargs.get("use_responses_api_bridge") is True - ), "use_responses_api_bridge flag should be forwarded to inner aresponses call" + call_kwargs.get("use_chat_completions_api") is True + ), "use_chat_completions_api should be forwarded to inner aresponses call" @patch( "litellm.responses.main.litellm_completion_transformation_handler.response_api_handler" @@ -223,6 +269,7 @@ class TestUseResponsesApiBridgeFlag: for call in mock_bridge_handler.call_args_list: all_kwargs = call.kwargs if call.kwargs else {} assert "use_responses_api_bridge" not in all_kwargs + assert "use_chat_completions_api" not in all_kwargs assert result is not None assert result.id is not None From 2506ccb2bc5d0ab3b8a9c5752c61c12a960b74c1 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Mon, 13 Apr 2026 18:22:17 +0530 Subject: [PATCH 05/79] refactor(responses): drop use_responses_api_bridge; fix PLR0915 - Only use_chat_completions_api and openai/chat_completions/ opt into the bridge - Extract MCP gateway and file_search emulation dispatch to cut responses() size - Update docs and tests Made-with: Cursor --- docs/my-website/docs/response_api.md | 5 +- litellm/responses/main.py | 379 ++++++++++++------ .../test_responses_api_bridge_flag.py | 41 +- 3 files changed, 272 insertions(+), 153 deletions(-) diff --git a/docs/my-website/docs/response_api.md b/docs/my-website/docs/response_api.md index 94f5c3e52f..20bb6d50d9 100644 --- a/docs/my-website/docs/response_api.md +++ b/docs/my-website/docs/response_api.md @@ -1509,11 +1509,10 @@ curl http://localhost:4000/v1/responses \ If you're using an **OpenAI-compatible third-party provider** (e.g. llama.cpp, vLLM, LM Studio) via `openai/` prefix with a custom `api_base`, LiteLLM will normally forward `/responses` requests directly to that endpoint. If the provider only supports `/chat/completions`, the request will fail. -Use any of these to force the `/responses` → `/chat/completions` bridge: +Use either of these to force the `/responses` → `/chat/completions` bridge: -1. **`use_chat_completions_api: true`** (recommended) — makes it explicit that LiteLLM will call the provider’s chat-completions API. +1. **`use_chat_completions_api: true`** — makes it explicit that LiteLLM will call the provider’s chat-completions API. 2. **`openai/chat_completions/`** — same pattern as `responses/` on chat completions: the model id encodes the routing choice. -3. **`use_responses_api_bridge: true`** — deprecated alias for `use_chat_completions_api` (kept for backward compatibility). #### Python SDK Usage diff --git a/litellm/responses/main.py b/litellm/responses/main.py index 91e173a7a8..edd936e734 100644 --- a/litellm/responses/main.py +++ b/litellm/responses/main.py @@ -661,7 +661,7 @@ def _normalize_openai_chat_completions_responses_model(model: str) -> tuple[str, def _pop_use_chat_completions_api_kw(kwargs: Dict[str, Any]) -> bool: - """Pop bridge flags; True if either requests the chat-completions path.""" + """Pop use_chat_completions_api; True when the chat-completions bridge is requested.""" use_cc = kwargs.pop("use_chat_completions_api", None) return bool(use_cc) @@ -728,6 +728,175 @@ def _apply_managed_file_id_mapping( return input, tools +def _responses_try_dispatch_mcp_gateway( + *, + tools: Optional[Iterable[ToolParam]], + input: Union[str, ResponseInputParam], + model: str, + include: Optional[List[ResponseIncludable]], + instructions: Optional[str], + max_output_tokens: Optional[int], + prompt: Optional[PromptObject], + metadata: Optional[Dict[str, Any]], + parallel_tool_calls: Optional[bool], + previous_response_id: Optional[str], + reasoning: Optional[Reasoning], + store: Optional[bool], + background: Optional[bool], + stream: Optional[bool], + temperature: Optional[float], + text: Any, + tool_choice: Optional[ToolChoice], + top_p: Optional[float], + truncation: Optional[Literal["auto", "disabled"]], + user: Optional[str], + extra_headers: Optional[Dict[str, Any]], + extra_query: Optional[Dict[str, Any]], + extra_body: Optional[Dict[str, Any]], + timeout: Optional[Union[float, httpx.Timeout]], + custom_llm_provider: Optional[str], + kwargs: Dict[str, Any], + _is_async: bool, +) -> Optional[Any]: + """Return a response when MCP gateway handles the call; otherwise None.""" + from litellm.responses.mcp.litellm_proxy_mcp_handler import ( + LiteLLM_Proxy_MCP_Handler, + ) + + if not LiteLLM_Proxy_MCP_Handler._should_use_litellm_mcp_gateway(tools=tools): + return None + mcp_call_kwargs = { + "input": input, + "model": model, + "include": include, + "instructions": instructions, + "max_output_tokens": max_output_tokens, + "prompt": prompt, + "metadata": metadata, + "parallel_tool_calls": parallel_tool_calls, + "previous_response_id": previous_response_id, + "reasoning": reasoning, + "store": store, + "background": background, + "stream": stream, + "temperature": temperature, + "text": text, + "tool_choice": tool_choice, + "tools": tools, + "top_p": top_p, + "truncation": truncation, + "user": user, + "extra_headers": extra_headers, + "extra_query": extra_query, + "extra_body": extra_body, + "timeout": timeout, + "custom_llm_provider": custom_llm_provider, + **kwargs, + } + if _is_async: + return aresponses_api_with_mcp(**mcp_call_kwargs) + return run_async_function(aresponses_api_with_mcp, **mcp_call_kwargs) + + +def _responses_try_dispatch_emulated_file_search( + *, + tools: Optional[Iterable[ToolParam]], + input: Union[str, ResponseInputParam], + model: str, + responses_api_provider_config: Optional[BaseResponsesAPIConfig], + use_chat_completions_api: bool, + include: Optional[List[ResponseIncludable]], + instructions: Optional[str], + max_output_tokens: Optional[int], + prompt: Optional[PromptObject], + metadata: Optional[Dict[str, Any]], + parallel_tool_calls: Optional[bool], + previous_response_id: Optional[str], + reasoning: Optional[Reasoning], + store: Optional[bool], + background: Optional[bool], + stream: Optional[bool], + temperature: Optional[float], + text: Any, + tool_choice: Optional[ToolChoice], + top_p: Optional[float], + truncation: Optional[Literal["auto", "disabled"]], + user: Optional[str], + service_tier: Optional[str], + safety_identifier: Optional[str], + text_format: Optional[Union[Type[BaseModel], dict]], + allowed_openai_params: Optional[List[str]], + extra_headers: Optional[Dict[str, Any]], + extra_query: Optional[Dict[str, Any]], + extra_body: Optional[Dict[str, Any]], + timeout: Optional[Union[float, httpx.Timeout]], + custom_llm_provider: Optional[str], + kwargs: Dict[str, Any], + _is_async: bool, +) -> Optional[Any]: + """Return a response when emulated file_search handles the call; otherwise None.""" + if not _has_file_search_tool(tools) or not ( + responses_api_provider_config is None + or use_chat_completions_api is True + or not responses_api_provider_config.supports_native_file_search() + ): + return None + from litellm.responses.file_search.emulated_handler import ( + aresponses_with_emulated_file_search, + ) + + _internal_skip = {"litellm_call_id", "aresponses"} + emulated_kwargs = { + "include": include, + "instructions": instructions, + "max_output_tokens": max_output_tokens, + "prompt": prompt, + "metadata": metadata, + "parallel_tool_calls": parallel_tool_calls, + "previous_response_id": previous_response_id, + "reasoning": reasoning, + "store": store, + "background": background, + "stream": stream, + "temperature": temperature, + "text": text, + "tool_choice": tool_choice, + "top_p": top_p, + "truncation": truncation, + "user": user, + "service_tier": service_tier, + "safety_identifier": safety_identifier, + "text_format": text_format, + "allowed_openai_params": allowed_openai_params, + "extra_headers": extra_headers, + "extra_query": extra_query, + "extra_body": extra_body, + "timeout": timeout, + "custom_llm_provider": custom_llm_provider, + **( + { + **( + {"use_chat_completions_api": True} + if use_chat_completions_api + else {} + ), + **{k: v for k, v in kwargs.items() if k not in _internal_skip}, + } + ), + } + if _is_async: + return aresponses_with_emulated_file_search( + input=input, model=model, tools=tools, **emulated_kwargs + ) + return run_async_function( + aresponses_with_emulated_file_search, + input=input, + model=model, + tools=tools, + **emulated_kwargs, + ) + + @client def responses( input: Union[str, ResponseInputParam], @@ -769,9 +938,6 @@ def responses( Uses the synchronous HTTP handler to make requests. """ local_vars = locals() - from litellm.responses.mcp.litellm_proxy_mcp_handler import ( - LiteLLM_Proxy_MCP_Handler, - ) try: litellm_logging_obj: LiteLLMLoggingObj = kwargs.get("litellm_logging_obj") # type: ignore @@ -841,38 +1007,37 @@ def responses( ######################################################### # Native MCP Responses API ######################################################### - if LiteLLM_Proxy_MCP_Handler._should_use_litellm_mcp_gateway(tools=tools): - mcp_call_kwargs = { - "input": input, - "model": model, - "include": include, - "instructions": instructions, - "max_output_tokens": max_output_tokens, - "prompt": prompt, - "metadata": metadata, - "parallel_tool_calls": parallel_tool_calls, - "previous_response_id": previous_response_id, - "reasoning": reasoning, - "store": store, - "background": background, - "stream": stream, - "temperature": temperature, - "text": text, - "tool_choice": tool_choice, - "tools": tools, - "top_p": top_p, - "truncation": truncation, - "user": user, - "extra_headers": extra_headers, - "extra_query": extra_query, - "extra_body": extra_body, - "timeout": timeout, - "custom_llm_provider": custom_llm_provider, - **kwargs, - } - if _is_async: - return aresponses_api_with_mcp(**mcp_call_kwargs) - return run_async_function(aresponses_api_with_mcp, **mcp_call_kwargs) + _mcp_dispatch = _responses_try_dispatch_mcp_gateway( + tools=tools, + input=input, + model=model, + include=include, + instructions=instructions, + max_output_tokens=max_output_tokens, + prompt=prompt, + metadata=metadata, + parallel_tool_calls=parallel_tool_calls, + previous_response_id=previous_response_id, + reasoning=reasoning, + store=store, + background=background, + stream=stream, + temperature=temperature, + text=text, + tool_choice=tool_choice, + top_p=top_p, + truncation=truncation, + user=user, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + custom_llm_provider=custom_llm_provider, + kwargs=kwargs, + _is_async=_is_async, + ) + if _mcp_dispatch is not None: + return _mcp_dispatch # get provider config responses_api_provider_config: Optional[BaseResponsesAPIConfig] @@ -902,61 +1067,43 @@ def responses( ) ) - if _has_file_search_tool(tools) and ( - responses_api_provider_config is None - or use_chat_completions_api is True - or not responses_api_provider_config.supports_native_file_search() - ): - from litellm.responses.file_search.emulated_handler import ( - aresponses_with_emulated_file_search, - ) - - _internal_skip = {"litellm_call_id", "aresponses"} - emulated_kwargs = { - "include": include, - "instructions": instructions, - "max_output_tokens": max_output_tokens, - "prompt": prompt, - "metadata": metadata, - "parallel_tool_calls": parallel_tool_calls, - "previous_response_id": previous_response_id, - "reasoning": reasoning, - "store": store, - "background": background, - "stream": stream, - "temperature": temperature, - "text": text, - "tool_choice": tool_choice, - "top_p": top_p, - "truncation": truncation, - "user": user, - "service_tier": service_tier, - "safety_identifier": safety_identifier, - "text_format": text_format, - "allowed_openai_params": allowed_openai_params, - "extra_headers": extra_headers, - "extra_query": extra_query, - "extra_body": extra_body, - "timeout": timeout, - "custom_llm_provider": custom_llm_provider, - **( - {"use_chat_completions_api": True} - if use_chat_completions_api - else {} - ), - **{k: v for k, v in kwargs.items() if k not in _internal_skip}, - } - if _is_async: - return aresponses_with_emulated_file_search( - input=input, model=model, tools=tools, **emulated_kwargs - ) - return run_async_function( - aresponses_with_emulated_file_search, - input=input, - model=model, - tools=tools, - **emulated_kwargs, - ) + _file_search_dispatch = _responses_try_dispatch_emulated_file_search( + tools=tools, + input=input, + model=model, + responses_api_provider_config=responses_api_provider_config, + use_chat_completions_api=use_chat_completions_api, + include=include, + instructions=instructions, + max_output_tokens=max_output_tokens, + prompt=prompt, + metadata=metadata, + parallel_tool_calls=parallel_tool_calls, + previous_response_id=previous_response_id, + reasoning=reasoning, + store=store, + background=background, + stream=stream, + temperature=temperature, + text=text, + tool_choice=tool_choice, + top_p=top_p, + truncation=truncation, + user=user, + service_tier=service_tier, + safety_identifier=safety_identifier, + text_format=text_format, + allowed_openai_params=allowed_openai_params, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + custom_llm_provider=custom_llm_provider, + kwargs=kwargs, + _is_async=_is_async, + ) + if _file_search_dispatch is not None: + return _file_search_dispatch if responses_api_provider_config is None or use_chat_completions_api is True: return litellm_completion_transformation_handler.response_api_handler( @@ -1154,11 +1301,11 @@ def delete_responses( raise ValueError("custom_llm_provider is required but passed as None") # get provider config - responses_api_provider_config: Optional[ - BaseResponsesAPIConfig - ] = ProviderConfigManager.get_provider_responses_api_config( - model=None, - provider=custom_llm_provider, + responses_api_provider_config: Optional[BaseResponsesAPIConfig] = ( + ProviderConfigManager.get_provider_responses_api_config( + model=None, + provider=custom_llm_provider, + ) ) if responses_api_provider_config is None: @@ -1335,11 +1482,11 @@ def get_responses( raise ValueError("custom_llm_provider is required but passed as None") # get provider config - responses_api_provider_config: Optional[ - BaseResponsesAPIConfig - ] = ProviderConfigManager.get_provider_responses_api_config( - model=None, - provider=custom_llm_provider, + responses_api_provider_config: Optional[BaseResponsesAPIConfig] = ( + ProviderConfigManager.get_provider_responses_api_config( + model=None, + provider=custom_llm_provider, + ) ) if responses_api_provider_config is None: @@ -1493,11 +1640,11 @@ def list_input_items( if custom_llm_provider is None: raise ValueError("custom_llm_provider is required but passed as None") - responses_api_provider_config: Optional[ - BaseResponsesAPIConfig - ] = ProviderConfigManager.get_provider_responses_api_config( - model=None, - provider=custom_llm_provider, + responses_api_provider_config: Optional[BaseResponsesAPIConfig] = ( + ProviderConfigManager.get_provider_responses_api_config( + model=None, + provider=custom_llm_provider, + ) ) if responses_api_provider_config is None: @@ -1652,11 +1799,11 @@ def cancel_responses( raise ValueError("custom_llm_provider is required but passed as None") # get provider config - responses_api_provider_config: Optional[ - BaseResponsesAPIConfig - ] = ProviderConfigManager.get_provider_responses_api_config( - model=None, - provider=custom_llm_provider, + responses_api_provider_config: Optional[BaseResponsesAPIConfig] = ( + ProviderConfigManager.get_provider_responses_api_config( + model=None, + provider=custom_llm_provider, + ) ) if responses_api_provider_config is None: @@ -1840,11 +1987,11 @@ def compact_responses( raise ValueError("custom_llm_provider is required but passed as None") # get provider config - responses_api_provider_config: Optional[ - BaseResponsesAPIConfig - ] = ProviderConfigManager.get_provider_responses_api_config( - model=model, - provider=custom_llm_provider, + responses_api_provider_config: Optional[BaseResponsesAPIConfig] = ( + ProviderConfigManager.get_provider_responses_api_config( + model=model, + provider=custom_llm_provider, + ) ) if responses_api_provider_config is None: diff --git a/tests/test_litellm/responses/test_responses_api_bridge_flag.py b/tests/test_litellm/responses/test_responses_api_bridge_flag.py index e635e12560..463af6562f 100644 --- a/tests/test_litellm/responses/test_responses_api_bridge_flag.py +++ b/tests/test_litellm/responses/test_responses_api_bridge_flag.py @@ -1,7 +1,6 @@ """ Tests for forcing the /responses → /chat/completions bridge for `openai/` models -(via `use_chat_completions_api`, deprecated `use_responses_api_bridge`, or the -`openai/chat_completions/` model id). +(via `use_chat_completions_api` or the `openai/chat_completions/` model id). Includes file_search emulation: the flag must be forwarded on inner aresponses calls so routed requests do not hit a custom api_base /v1/responses endpoint. @@ -22,28 +21,6 @@ from litellm.types.llms.openai import ResponseAPIUsage, ResponsesAPIResponse class TestUseResponsesApiBridgeFlag: """Test that bridge opt-in forces the chat completions path.""" - @patch( - "litellm.responses.main.litellm_completion_transformation_handler.response_api_handler" - ) - @patch( - "litellm.responses.main.ProviderConfigManager.get_provider_responses_api_config" - ) - def test_bridge_used_when_flag_is_true(self, mock_get_config, mock_bridge_handler): - """When use_responses_api_bridge=True (deprecated alias), the bridge runs.""" - # Setup: provider config returns a non-None config (native support exists) - mock_get_config.return_value = litellm.OpenAIResponsesAPIConfig() - - mock_bridge_handler.return_value = MagicMock() - - litellm.responses( - model="openai/my-custom-model", - input="Hello", - use_responses_api_bridge=True, - litellm_logging_obj=MagicMock(), - ) - - mock_bridge_handler.assert_called_once() - @patch( "litellm.responses.main.litellm_completion_transformation_handler.response_api_handler" ) @@ -96,7 +73,7 @@ class TestUseResponsesApiBridgeFlag: def test_native_forwarding_when_flag_absent( self, mock_get_config, mock_native_handler ): - """When use_responses_api_bridge is not set, openai/ models should use + """When use_chat_completions_api is not set, openai/ models should use native responses API forwarding (existing behavior).""" mock_get_config.return_value = litellm.OpenAIResponsesAPIConfig() mock_native_handler.return_value = MagicMock() @@ -116,22 +93,19 @@ class TestUseResponsesApiBridgeFlag: "litellm.responses.main.ProviderConfigManager.get_provider_responses_api_config" ) def test_flag_does_not_leak_into_kwargs(self, mock_get_config, mock_bridge_handler): - """The use_responses_api_bridge flag should be popped from kwargs and not - passed through to the bridge handler.""" + """use_chat_completions_api should be popped and not passed to the bridge handler.""" mock_get_config.return_value = litellm.OpenAIResponsesAPIConfig() mock_bridge_handler.return_value = MagicMock() litellm.responses( model="openai/my-custom-model", input="Hello", - use_responses_api_bridge=True, + use_chat_completions_api=True, litellm_logging_obj=MagicMock(), ) call_kwargs = mock_bridge_handler.call_args - # Bridge flags should not appear in the kwargs passed to the bridge handler all_kwargs = call_kwargs.kwargs if call_kwargs.kwargs else {} - assert "use_responses_api_bridge" not in all_kwargs assert "use_chat_completions_api" not in all_kwargs @patch( @@ -163,7 +137,7 @@ class TestUseResponsesApiBridgeFlag: async def test_bridge_flag_forwarded_to_file_search_emulation( self, mock_get_config, mock_call_aresponses ): - """When use_responses_api_bridge=True and file_search tool is present, + """When use_chat_completions_api=True and file_search tool is present, the flag should be forwarded to the inner aresponses call in the file_search emulation path.""" # Setup: provider has native responses API support @@ -187,7 +161,7 @@ class TestUseResponsesApiBridgeFlag: model="openai/my-custom-model", input="Search for information", tools=[{"type": "file_search"}], - use_responses_api_bridge=True, + use_chat_completions_api=True, litellm_logging_obj=MagicMock(), ) @@ -257,7 +231,7 @@ class TestUseResponsesApiBridgeFlag: "file_search": {"vector_store_ids": ["vs_123"]}, } ], - use_responses_api_bridge=True, + use_chat_completions_api=True, api_base="http://localhost:8080/v1", litellm_logging_obj=MagicMock(), ) @@ -268,7 +242,6 @@ class TestUseResponsesApiBridgeFlag: ) for call in mock_bridge_handler.call_args_list: all_kwargs = call.kwargs if call.kwargs else {} - assert "use_responses_api_bridge" not in all_kwargs assert "use_chat_completions_api" not in all_kwargs assert result is not None assert result.id is not None From f122aa1b736e4f349ffbf15a11997ca4df07a541 Mon Sep 17 00:00:00 2001 From: shivam Date: Tue, 14 Apr 2026 20:47:57 -0700 Subject: [PATCH 06/79] fix(router): restore BYOK key injection for vector store endpoints with team-scoped deployments When vector store endpoints (POST/GET /v1/vector_stores) are called, model=None is passed to the router. map_team_model(None, team_id) was returning None unchanged after the team model routing fix in #25148, so the router never found the team's BYOK deployment and forwarded requests without the API key. Fix: when team_model_name is None, return the matched deployment's team_public_model_name (or model_name fallback) so the router can route to it and inject the BYOK credentials. Does not affect the sibling-deployment load-balancing fix since that only applies when a non-None model is passed. Co-Authored-By: Claude Sonnet 4.6 --- litellm/router.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/litellm/router.py b/litellm/router.py index 9185e437a3..89275fa902 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -8321,7 +8321,9 @@ class Router: # No match found return None - def map_team_model(self, team_model_name: str, team_id: str) -> Optional[str]: + def map_team_model( + self, team_model_name: Optional[str], team_id: str + ) -> Optional[str]: """ Check if team_model_name resolves to team-specific deployments. @@ -8329,6 +8331,11 @@ class Router: sibling deployments via team_id filtering, instead of collapsing to a single internal model_name. + When team_model_name is None (e.g. vector store / file endpoints that + don't include a model in their request), returns the first matching + team deployment's team_public_model_name so the router can inject BYOK + credentials from the team-scoped deployment. + Returns: - str: the team_model_name if team deployments exist for this team - None: if no team-specific model is found @@ -8338,6 +8345,13 @@ class Router: return None for model in models: if model.get("model_info", {}).get("team_id") == team_id: + if team_model_name is None: + # No model was specified (e.g. vector store endpoints). + # Return the deployment's public model name so the router + # can route to it and inject the BYOK API key. + return model.get("model_info", {}).get( + "team_public_model_name" + ) or model.get("model_name") return team_model_name # No team-scoped deployment found; wildcard/pattern routes are From 9a3d9b263241c97e58115bf7a0260ebb8166fe0f Mon Sep 17 00:00:00 2001 From: Vinh Pham Huu Date: Wed, 15 Apr 2026 17:11:12 +0700 Subject: [PATCH 07/79] 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 08/79] 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 09/79] 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 10/79] 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 11/79] 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 e5f3e1596902ac2841a0fff2d1caff6c95d79c52 Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Tue, 21 Apr 2026 13:56:44 -0700 Subject: [PATCH 12/79] Track per-member total spend on team memberships Adds total_spend column to LiteLLM_TeamMembership that accumulates continuously and is not zeroed by the budget cycle reset job. This enables UI surfaces to distinguish current-cycle spend (the existing spend column, which resets) from lifetime spend per team member. Also exposes budget_reset_at on LiteLLM_BudgetTable so /team/info callers can see when a member's budget window next resets. The field was already stored in the DB but stripped by the response Pydantic model. Includes regression tests that: - Guard the reset job against ever writing total_spend: 0 - Verify the spend writer increments both spend and total_spend in one UPDATE statement. --- .../migration.sql | 3 + .../litellm_proxy_extras/schema.prisma | 1 + litellm/proxy/_types.py | 3 +- litellm/proxy/db/db_spend_update_writer.py | 5 +- litellm/proxy/schema.prisma | 1 + schema.prisma | 1 + .../common_utils/test_reset_budget_job.py | 35 +++++++++ .../proxy/db/test_db_spend_update_writer.py | 75 +++++++++++++++++++ 8 files changed, 122 insertions(+), 2 deletions(-) create mode 100644 litellm-proxy-extras/litellm_proxy_extras/migrations/20260421135425_add_team_membership_total_spend/migration.sql diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260421135425_add_team_membership_total_spend/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260421135425_add_team_membership_total_spend/migration.sql new file mode 100644 index 0000000000..049bd513cd --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260421135425_add_team_membership_total_spend/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "LiteLLM_TeamMembership" ADD COLUMN "total_spend" DOUBLE PRECISION NOT NULL DEFAULT 0.0; + diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma index 08aa564525..e18662b572 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma +++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma @@ -616,6 +616,7 @@ model LiteLLM_TeamMembership { user_id String team_id String spend Float @default(0.0) + total_spend Float @default(0.0) budget_id String? litellm_budget_table LiteLLM_BudgetTable? @relation(fields: [budget_id], references: [budget_id]) @@id([user_id, team_id]) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 85d3df7189..819a38eec1 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -2007,6 +2007,7 @@ class LiteLLM_BudgetTable(LiteLLMPydanticObjectBase): rpm_limit: Optional[int] = None model_max_budget: Optional[dict] = None budget_duration: Optional[str] = None + budget_reset_at: Optional[datetime] = None allowed_models: Optional[List[str]] = ( None # per-member model scope; empty = inherit team models ) @@ -2017,7 +2018,6 @@ class LiteLLM_BudgetTable(LiteLLMPydanticObjectBase): class LiteLLM_BudgetTableFull(LiteLLM_BudgetTable): """Represents all params for a LiteLLM_BudgetTable record""" - budget_reset_at: Optional[datetime] = None created_at: datetime @@ -3695,6 +3695,7 @@ class LiteLLM_TeamMembership(LiteLLMPydanticObjectBase): team_id: str budget_id: Optional[str] = None spend: Optional[float] = 0.0 + total_spend: Optional[float] = 0.0 litellm_budget_table: Optional[LiteLLM_BudgetTable] def safe_get_team_member_rpm_limit(self) -> Optional[int]: diff --git a/litellm/proxy/db/db_spend_update_writer.py b/litellm/proxy/db/db_spend_update_writer.py index 8017448ae1..c06e1850d9 100644 --- a/litellm/proxy/db/db_spend_update_writer.py +++ b/litellm/proxy/db/db_spend_update_writer.py @@ -1300,7 +1300,10 @@ class DBSpendUpdateWriter: batcher.litellm_teammembership.update_many( # 'update_many' prevents error from being raised if no row exists where={"team_id": team_id, "user_id": user_id}, - data={"spend": {"increment": response_cost}}, + data={ + "spend": {"increment": response_cost}, + "total_spend": {"increment": response_cost}, + }, ) # Transaction succeeded, break out of retry loop break diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index 08aa564525..e18662b572 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -616,6 +616,7 @@ model LiteLLM_TeamMembership { user_id String team_id String spend Float @default(0.0) + total_spend Float @default(0.0) budget_id String? litellm_budget_table LiteLLM_BudgetTable? @relation(fields: [budget_id], references: [budget_id]) @@id([user_id, team_id]) diff --git a/schema.prisma b/schema.prisma index 08aa564525..e18662b572 100644 --- a/schema.prisma +++ b/schema.prisma @@ -616,6 +616,7 @@ model LiteLLM_TeamMembership { user_id String team_id String spend Float @default(0.0) + total_spend Float @default(0.0) budget_id String? litellm_budget_table LiteLLM_BudgetTable? @relation(fields: [budget_id], references: [budget_id]) @@id([user_id, team_id]) 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 8206079cb8..32f043be5b 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 @@ -4,6 +4,7 @@ import sys import time from datetime import datetime, timedelta, timezone from typing import Any, Dict, List +from unittest.mock import AsyncMock, MagicMock import pytest @@ -784,3 +785,37 @@ def test_reset_budget_skips_null_budget_id_endusers_when_default_not_in_reset_li assert len(find_many_calls) == 0 litellm.max_end_user_budget_id = None + + +def test_reset_budget_for_team_members_preserves_total_spend(): + """Regression guard: reset_budget_for_litellm_team_members must zero `spend` + but leave `total_spend` untouched. + + The reset writes `data={"spend": 0}` explicitly. If a future refactor adds + `"total_spend": 0` to that dict, this test fails immediately. + """ + expired_budget = type( + "LiteLLM_BudgetTableFull", + (), + {"budget_id": "budget-1"}, + ) + + mock_prisma_client = MagicMock() + mock_prisma_client.db.litellm_teammembership.find_many = AsyncMock(return_value=[]) + mock_prisma_client.db.litellm_teammembership.update_many = AsyncMock( + return_value={"count": 1} + ) + + job = ResetBudgetJob( + proxy_logging_obj=MagicMock(), prisma_client=mock_prisma_client + ) + + asyncio.run(job.reset_budget_for_litellm_team_members([expired_budget])) + + mock_prisma_client.db.litellm_teammembership.update_many.assert_called_once() + call_kwargs = ( + mock_prisma_client.db.litellm_teammembership.update_many.call_args.kwargs + ) + assert call_kwargs["where"]["budget_id"]["in"] == ["budget-1"] + assert call_kwargs["data"] == {"spend": 0} + assert "total_spend" not in call_kwargs["data"] diff --git a/tests/test_litellm/proxy/db/test_db_spend_update_writer.py b/tests/test_litellm/proxy/db/test_db_spend_update_writer.py index b98b9a8ad6..4d58434934 100644 --- a/tests/test_litellm/proxy/db/test_db_spend_update_writer.py +++ b/tests/test_litellm/proxy/db/test_db_spend_update_writer.py @@ -642,6 +642,81 @@ async def test_commit_spend_updates_to_db_increments_agent_spend(): assert call_kwargs["data"] == {"spend": {"increment": response_cost}} +@pytest.mark.asyncio +async def test_commit_spend_updates_to_db_increments_team_member_spend_and_total_spend(): + """ + Verify that _commit_spend_updates_to_db increments BOTH spend (cycle-scoped) + and total_spend (non-resetting) on LiteLLM_TeamMembership in a single + update_many call, using the same response_cost. + """ + db_writer = DBSpendUpdateWriter() + + mock_batcher = MagicMock() + mock_batcher.litellm_verificationtoken = MagicMock() + mock_batcher.litellm_verificationtoken.update_many = MagicMock() + mock_batcher.litellm_usertable = MagicMock() + mock_batcher.litellm_usertable.update_many = MagicMock() + mock_batcher.litellm_teamtable = MagicMock() + mock_batcher.litellm_teamtable.update_many = MagicMock() + mock_batcher.litellm_teammembership = MagicMock() + mock_batcher.litellm_teammembership.update_many = MagicMock() + mock_batcher.litellm_organizationtable = MagicMock() + mock_batcher.litellm_organizationtable.update_many = MagicMock() + mock_batcher.litellm_tagtable = MagicMock() + mock_batcher.litellm_tagtable.update_many = MagicMock() + mock_batcher.litellm_agentstable = MagicMock() + mock_batcher.litellm_agentstable.update_many = MagicMock() + + mock_transaction = AsyncMock() + mock_transaction.__aenter__ = AsyncMock(return_value=mock_transaction) + mock_transaction.__aexit__ = AsyncMock(return_value=False) + mock_transaction.batch_ = MagicMock( + return_value=AsyncMock( + __aenter__=AsyncMock(return_value=mock_batcher), + __aexit__=AsyncMock(return_value=False), + ) + ) + + mock_prisma_client = MagicMock() + mock_prisma_client.db = MagicMock() + mock_prisma_client.db.tx = MagicMock(return_value=mock_transaction) + + mock_proxy_logging = MagicMock() + # Skip team-membership cache invalidation — out of scope for this test. + mock_proxy_logging.call_details.get = MagicMock(return_value=None) + + team_id = "team-abc" + user_id = "user-xyz" + response_cost = 0.75 + entity_id = f"team_id::{team_id}::user_id::{user_id}" + db_spend_update_transactions = { + "user_list_transactions": {}, + "end_user_list_transactions": {}, + "key_list_transactions": {}, + "team_list_transactions": {}, + "team_member_list_transactions": {entity_id: response_cost}, + "org_list_transactions": {}, + "tag_list_transactions": {}, + "agent_list_transactions": {}, + } + + with patch("litellm.proxy.utils._raise_failed_update_spend_exception"): + await db_writer._commit_spend_updates_to_db( + prisma_client=mock_prisma_client, + n_retry_times=0, + proxy_logging_obj=mock_proxy_logging, + db_spend_update_transactions=db_spend_update_transactions, + ) + + mock_batcher.litellm_teammembership.update_many.assert_called_once() + call_kwargs = mock_batcher.litellm_teammembership.update_many.call_args[1] + assert call_kwargs["where"] == {"team_id": team_id, "user_id": user_id} + assert call_kwargs["data"] == { + "spend": {"increment": response_cost}, + "total_spend": {"increment": response_cost}, + } + + @pytest.mark.asyncio async def test_add_spend_log_transaction_to_daily_tag_transaction_with_request_id(): """ From 1a0ac9634cd4bcef0044fc0c6c8aefc870679f7d Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Tue, 21 Apr 2026 15:38:58 -0700 Subject: [PATCH 13/79] Keep budget_reset_at off the user-settable budget allowlist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LiteLLM_BudgetTable is documented as "user-controllable params" and its model_fields.keys() is used as the allowlist for extracting budget fields from incoming API request bodies (management_helpers/utils.py:88, organization_endpoints.py:112/255/537/549, project_endpoints.py:197/245/632, customer_endpoints.py:598). Request models like NewOrganizationRequest inherit from LiteLLM_BudgetTable, so anything on the base class becomes user-settable — a caller could set budget_reset_at far in the future and evade budget cycling. Move budget_reset_at from the base class to LiteLLM_BudgetTableFull so it appears on API responses without becoming writable, and type LiteLLM_TeamMembership.litellm_budget_table as Union[Full, Base] so Pydantic picks Full when the data has server-managed fields (/team/info reads Prisma rows that include budget_reset_at and created_at) and Base when callers construct with only user-settable fields (existing auth tests and caches). --- litellm/proxy/_types.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 819a38eec1..9e3cd18ff5 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -1997,7 +1997,12 @@ class TeamRequest(LiteLLMPydanticObjectBase): class LiteLLM_BudgetTable(LiteLLMPydanticObjectBase): - """Represents user-controllable params for a LiteLLM_BudgetTable record""" + """Represents user-controllable params for a LiteLLM_BudgetTable record. + + Budget-write paths use `model_fields.keys()` on this class as an allowlist + for user input. Keep server-managed fields (e.g. `budget_reset_at`) on + `LiteLLM_BudgetTableFull` so they aren't user-settable. + """ budget_id: Optional[str] = None soft_budget: Optional[float] = None @@ -2007,7 +2012,6 @@ class LiteLLM_BudgetTable(LiteLLMPydanticObjectBase): rpm_limit: Optional[int] = None model_max_budget: Optional[dict] = None budget_duration: Optional[str] = None - budget_reset_at: Optional[datetime] = None allowed_models: Optional[List[str]] = ( None # per-member model scope; empty = inherit team models ) @@ -2016,8 +2020,9 @@ class LiteLLM_BudgetTable(LiteLLMPydanticObjectBase): class LiteLLM_BudgetTableFull(LiteLLM_BudgetTable): - """Represents all params for a LiteLLM_BudgetTable record""" + """LiteLLM_BudgetTable + server-managed fields returned on API responses.""" + budget_reset_at: Optional[datetime] = None created_at: datetime @@ -3696,7 +3701,12 @@ class LiteLLM_TeamMembership(LiteLLMPydanticObjectBase): budget_id: Optional[str] = None spend: Optional[float] = 0.0 total_spend: Optional[float] = 0.0 - litellm_budget_table: Optional[LiteLLM_BudgetTable] + # Union so Pydantic picks Full when data has server-managed fields + # (/team/info) and Base when callers/tests construct with only + # user-settable fields. + litellm_budget_table: Optional[ + Union[LiteLLM_BudgetTableFull, LiteLLM_BudgetTable] + ] def safe_get_team_member_rpm_limit(self) -> Optional[int]: if self.litellm_budget_table is not None: From 9a6ddef09fd17659f75cabbba993d7123d6c4a0b Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Tue, 21 Apr 2026 15:46:51 -0700 Subject: [PATCH 14/79] fmt: apply black to _types.py --- litellm/proxy/_types.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 9e3cd18ff5..84a9c4b793 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -3704,9 +3704,7 @@ class LiteLLM_TeamMembership(LiteLLMPydanticObjectBase): # Union so Pydantic picks Full when data has server-managed fields # (/team/info) and Base when callers/tests construct with only # user-settable fields. - litellm_budget_table: Optional[ - Union[LiteLLM_BudgetTableFull, LiteLLM_BudgetTable] - ] + litellm_budget_table: Optional[Union[LiteLLM_BudgetTableFull, LiteLLM_BudgetTable]] def safe_get_team_member_rpm_limit(self) -> Optional[int]: if self.litellm_budget_table is not None: From 0bd49ecb8b497efd8f7ddfb56a07a48ac7c5a017 Mon Sep 17 00:00:00 2001 From: Michael Riad Zaky Date: Fri, 17 Apr 2026 16:58:26 -0700 Subject: [PATCH 15/79] 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 16/79] 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 17/79] 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 331e3f22508ca52c62ff52b16ef8f7f330259a17 Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Wed, 22 Apr 2026 15:00:13 -0700 Subject: [PATCH 18/79] Surface per-member total spend in Teams > Members tab Adds a "Total Spend (USD)" column backed by the new membership.total_spend field. Cumulative across budget cycles; tracking began 2026-04-21. --- .../src/components/team/TeamInfo.tsx | 1 + .../src/components/team/TeamMemberTab.tsx | 13 ++++++------- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx index 909b079e6a..4bc7ff3ea8 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx @@ -60,6 +60,7 @@ export interface TeamMembership { team_id: string; budget_id: string; spend: number; + total_spend: number | null; litellm_budget_table: { budget_id: string; soft_budget: number | null; diff --git a/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx b/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx index e880aa49f6..7c9e47d9e1 100644 --- a/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx @@ -45,11 +45,10 @@ export default function TeamMemberTab({ return "0"; }; - // Helper function to get spend for a user - const getUserSpend = (userId: string | null): number | null => { + const getUserTotalSpend = (userId: string | null): number => { if (!userId) return 0; const membership = teamData.team_memberships.find((tm) => tm.user_id === userId); - return membership?.spend || 0; + return membership?.total_spend ?? 0; }; const getUserBudget = (userId: string | null): string | null => { @@ -124,15 +123,15 @@ export default function TeamMemberTab({ { title: ( - Team Member Spend (USD) - + Total Spend (USD) + ), - key: "spend", + key: "total_spend", render: (_: unknown, record: Member) => ( - ${formatNumberWithCommas(getUserSpend(record.user_id), 4)} + ${formatNumberWithCommas(getUserTotalSpend(record.user_id), 4)} ), }, { From 28e1d2f1a638b9f5dfc92b22620834e101f2d70f Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 22 Apr 2026 16:14:24 -0700 Subject: [PATCH 19/79] [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 20/79] 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 5e5a94ac8d436fe191497dd3723d043dac25f5b2 Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Wed, 22 Apr 2026 17:28:05 -0700 Subject: [PATCH 21/79] Surface budget_reset_at on team info and members tab Adds a formatBudgetReset helper (dayjs-based, with validity guard) that renders the next reset as "today" / "in N days" / "on MMM D, YYYY". The team budget card now shows the team's reset timestamp and the member- default reset (when a shared team_member_budget is configured), and the Members tab gains a Budget Reset column per member. --- .../src/components/team/TeamInfo.tsx | 20 +++++++++++++++---- .../src/components/team/TeamMemberTab.tsx | 19 ++++++++++++++++++ ui/litellm-dashboard/src/utils/budgetUtils.ts | 13 ++++++++++++ 3 files changed, 48 insertions(+), 4 deletions(-) create mode 100644 ui/litellm-dashboard/src/utils/budgetUtils.ts diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx index 4bc7ff3ea8..04b9b53140 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx @@ -15,6 +15,7 @@ import { teamUpdateCall, } from "@/components/networking"; import { useGuardrails } from "@/app/(dashboard)/hooks/guardrails/useGuardrails"; +import { formatBudgetReset } from "@/utils/budgetUtils"; import { formatNumberWithCommas } from "@/utils/dataUtils"; import { mapEmptyStringToNull } from "@/utils/keyUpdateUtils"; import { isProxyAdminRole } from "@/utils/roles"; @@ -70,6 +71,7 @@ export interface TeamMembership { rpm_limit: number | null; model_max_budget: Record | null; budget_duration: string | null; + budget_reset_at: string | null; allowed_models?: string[] | null; }; } @@ -120,6 +122,7 @@ export interface TeamData { team_member_budget_table: { max_budget: number; budget_duration: string; + budget_reset_at: string | null; tpm_limit: number | null; rpm_limit: number | null; } | null; @@ -732,12 +735,21 @@ const TeamInfoView: React.FC = ({ of {info.max_budget === null ? "Unlimited" : `$${formatNumberWithCommas(info.max_budget, 4)}`} - {info.budget_duration && Reset: {info.budget_duration}} + {formatBudgetReset(info.budget_reset_at) && ( + Resets {formatBudgetReset(info.budget_reset_at)} + )}
{info.team_member_budget_table && ( - - Team Member Budget: ${formatNumberWithCommas(info.team_member_budget_table.max_budget, 4)} - + <> + + Team Member Budget: ${formatNumberWithCommas(info.team_member_budget_table.max_budget, 4)} + + {formatBudgetReset(info.team_member_budget_table.budget_reset_at) && ( + + Member budgets reset {formatBudgetReset(info.team_member_budget_table.budget_reset_at)} + + )} + )} diff --git a/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx b/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx index 7c9e47d9e1..e997875968 100644 --- a/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx @@ -1,6 +1,7 @@ import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { Member } from "@/components/networking"; +import { formatBudgetReset } from "@/utils/budgetUtils"; import { formatNumberWithCommas } from "@/utils/dataUtils"; import { isProxyAdminRole, isUserTeamAdminForSingleTeam } from "@/utils/roles"; import { InfoCircleOutlined } from "@ant-design/icons"; @@ -88,6 +89,12 @@ export default function TeamMemberTab({ return models && models.length > 0 ? models : null; }; + const getUserBudgetReset = (userId: string | null): string | null => { + if (!userId) return null; + const membership = teamData.team_memberships.find((tm) => tm.user_id === userId); + return formatBudgetReset(membership?.litellm_budget_table?.budget_reset_at); + }; + const extraColumns: ColumnsType = [ { title: ( @@ -146,6 +153,18 @@ export default function TeamMemberTab({ ); }, }, + { + title: "Budget Reset", + key: "budget_reset", + render: (_: unknown, record: Member) => { + const reset = getUserBudgetReset(record.user_id); + return reset ? ( + {reset} + ) : ( + + ); + }, + }, { title: ( diff --git a/ui/litellm-dashboard/src/utils/budgetUtils.ts b/ui/litellm-dashboard/src/utils/budgetUtils.ts new file mode 100644 index 0000000000..ba13528bee --- /dev/null +++ b/ui/litellm-dashboard/src/utils/budgetUtils.ts @@ -0,0 +1,13 @@ +import dayjs from "dayjs"; + +export function formatBudgetReset(iso: string | null | undefined): string | null { + if (!iso) return null; + const resetDate = dayjs(iso); + if (!resetDate.isValid()) return null; + + const days = resetDate.diff(dayjs(), "day"); + if (days < 0) return `on ${resetDate.format("MMM D, YYYY")}`; + if (days === 0) return "today"; + if (days < 7) return `in ${days} day${days === 1 ? "" : "s"}`; + return `on ${resetDate.format("MMM D, YYYY")}`; +} From a23edd73b14aab0271349e1c4b811cb08d79f6df Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Wed, 22 Apr 2026 17:33:41 -0700 Subject: [PATCH 22/79] Restore Current Cycle Spend column on team members tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds back the per-cycle spend column that was replaced by Total Spend in 331e3f22. Current Cycle Spend reads membership.spend (zeroed on budget_reset_at) — this is the value enforced against the member's budget, so admins need it to see whether a member is approaching their cap for the active window. Total Spend remains for lifetime analytics. --- .../src/components/team/TeamMemberTab.tsx | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx b/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx index e997875968..1f2046fb90 100644 --- a/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx @@ -46,6 +46,12 @@ export default function TeamMemberTab({ return "0"; }; + const getUserCurrentCycleSpend = (userId: string | null): number => { + if (!userId) return 0; + const membership = teamData.team_memberships.find((tm) => tm.user_id === userId); + return membership?.spend ?? 0; + }; + const getUserTotalSpend = (userId: string | null): number => { if (!userId) return 0; const membership = teamData.team_memberships.find((tm) => tm.user_id === userId); @@ -127,11 +133,25 @@ export default function TeamMemberTab({ ); }, }, + { + title: ( + + Current Cycle Spend (USD) + + + + + ), + key: "spend", + render: (_: unknown, record: Member) => ( + ${formatNumberWithCommas(getUserCurrentCycleSpend(record.user_id), 4)} + ), + }, { title: ( Total Spend (USD) - + From a292845dcf7d4929b5b842171b659ed31a86b4c8 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 22 Apr 2026 17:40:42 -0700 Subject: [PATCH 23/79] [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 c67d193400eb05779384196fd170079372ad0e56 Mon Sep 17 00:00:00 2001 From: michelligabriele Date: Thu, 23 Apr 2026 03:00:04 +0200 Subject: [PATCH 24/79] fix(docker.non_root): use numeric UID 65534 for K8s runAsNonRoot (#26268) --- docker/Dockerfile.non_root | 2 +- docker/tests/nonroot.yaml | 2 +- .../test_litellm/test_dockerfile_non_root.py | 54 +++++++++++++++++++ 3 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 tests/test_litellm/test_dockerfile_non_root.py diff --git a/docker/Dockerfile.non_root b/docker/Dockerfile.non_root index e916167609..3666a850d9 100644 --- a/docker/Dockerfile.non_root +++ b/docker/Dockerfile.non_root @@ -138,7 +138,7 @@ RUN mkdir -p /nonexistent /var/lib/litellm/assets /var/lib/litellm/ui && \ [ -n "$LITELLM_PROXY_EXTRAS_PATH" ] && chmod -R g+w "$LITELLM_PROXY_EXTRAS_PATH" || true && \ chmod -R g+rX "$PRISMA_PATH" /var/lib/litellm/ui /var/lib/litellm/assets /app/.cache -USER nobody +USER 65534 RUN prisma generate --schema=./schema.prisma diff --git a/docker/tests/nonroot.yaml b/docker/tests/nonroot.yaml index 821b1a105a..36118ca8c5 100644 --- a/docker/tests/nonroot.yaml +++ b/docker/tests/nonroot.yaml @@ -2,7 +2,7 @@ schemaVersion: 2.0.0 metadataTest: entrypoint: ["docker/prod_entrypoint.sh"] - user: "nobody" + user: "65534" workdir: "/app" fileExistenceTests: diff --git a/tests/test_litellm/test_dockerfile_non_root.py b/tests/test_litellm/test_dockerfile_non_root.py new file mode 100644 index 0000000000..694da6368e --- /dev/null +++ b/tests/test_litellm/test_dockerfile_non_root.py @@ -0,0 +1,54 @@ +""" +Static checks on docker/Dockerfile.non_root. + +The non_root image is intended for deployment into hardened Kubernetes +clusters where `securityContext.runAsNonRoot: true` is enforced. The +kubelet validates non-root status by parsing the image's USER field as +an integer — a string name like "nobody" is rejected with +CreateContainerConfigError because the kubelet cannot resolve +/etc/passwd inside the image at admission time. +""" + +import os +import re + +import pytest + +DOCKERFILE_PATH = os.path.join( + os.path.dirname(__file__), + "..", + "..", + "docker", + "Dockerfile.non_root", +) + + +def _final_user_directive(dockerfile_text: str) -> str: + """Return the value of the last `USER` directive in the file.""" + matches = re.findall(r"^USER\s+(\S+)\s*$", dockerfile_text, re.MULTILINE) + assert matches, "Dockerfile.non_root has no USER directive" + return matches[-1] + + +@pytest.mark.skipif( + not os.path.exists(DOCKERFILE_PATH), + reason="Dockerfile.non_root not present in this checkout", +) +def test_final_user_directive_is_numeric(): + """The runtime USER must be a numeric UID so kubelet's runAsNonRoot + admission check (strconv.Atoi) succeeds.""" + with open(DOCKERFILE_PATH, "r", encoding="utf-8") as f: + contents = f.read() + + final_user = _final_user_directive(contents) + + assert final_user.isdigit(), ( + f"Dockerfile.non_root final USER is {final_user!r}; must be a numeric UID " + "so Kubernetes' runAsNonRoot admission check can verify non-root status. " + "See https://kubernetes.io/docs/tasks/configure-pod-container/security-context/" + ) + + assert int(final_user) != 0, ( + f"Dockerfile.non_root final USER is {final_user} (root); the non_root image " + "must run as a non-zero UID." + ) From 375bf4d7d67a6f9a1ead0512e90d68c05b12219f Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 22 Apr 2026 18:04:39 -0700 Subject: [PATCH 25/79] 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 26/79] 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 27/79] =?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 28/79] =?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 29/79] 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 ac453c958ed438efe1fc63e9d3a315233de99a1e Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Wed, 22 Apr 2026 18:27:36 -0700 Subject: [PATCH 30/79] fix(team): surface budget_reset_at on /team/info and cloned member budgets Two independent bugs both masked budget_reset_at from consumers that needed it: 1. /team/info.team_member_budget_table was typed as LiteLLM_BudgetTable (the user-settable allowlist), which dropped server-managed fields. Switched to LiteLLM_BudgetTableFull so budget_reset_at and created_at are serialized. 2. _clone_team_default_budget_for_member copied the pool's numeric fields but never set budget_reset_at on the cloned row. With budget_duration present but no reset timestamp, the reset job never fires on the member's budget (its query is reset_at <= now, which never matches NULL). Now computes budget_reset_at from the cloned budget_duration via get_budget_reset_time so each member's cycle starts at clone time rather than inheriting the pool's stale reset. --- litellm/proxy/_types.py | 2 +- litellm/proxy/management_helpers/utils.py | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 84a9c4b793..f11b29103b 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -3907,7 +3907,7 @@ class OrganizationMemberUpdateResponse(MemberUpdateResponse): class TeamInfoResponseObjectTeamTable(LiteLLM_TeamTable): - team_member_budget_table: Optional[LiteLLM_BudgetTable] = None + team_member_budget_table: Optional[LiteLLM_BudgetTableFull] = None # Resources inherited from access groups (separate from direct assignments) access_group_models: Optional[List[str]] = None access_group_mcp_server_ids: Optional[List[str]] = None diff --git a/litellm/proxy/management_helpers/utils.py b/litellm/proxy/management_helpers/utils.py index 5cf53ae06f..f2d6e9612f 100644 --- a/litellm/proxy/management_helpers/utils.py +++ b/litellm/proxy/management_helpers/utils.py @@ -9,6 +9,7 @@ from fastapi import HTTPException, Request import litellm from litellm._logging import verbose_logger from litellm._uuid import uuid +from litellm.proxy.common_utils.timezone_utils import get_budget_reset_time from litellm.proxy._types import ( # key request types; user request types; team request types; customer request types BudgetNewRequest, DeleteCustomerRequest, @@ -192,6 +193,13 @@ async def _clone_team_default_budget_for_member( continue cloned_data[field] = value + # Start the member's budget window at clone time, not the pool's reset + # timestamp — otherwise a member joining mid-cycle inherits a stale reset. + if cloned_data.get("budget_duration"): + cloned_data["budget_reset_at"] = get_budget_reset_time( + cloned_data["budget_duration"] + ) + new_budget = await prisma_client.db.litellm_budgettable.create(data=cloned_data) return new_budget.budget_id From 034f4fdef20fb9b8ab5c63787e2ba764ad4661cc Mon Sep 17 00:00:00 2001 From: sakenuGOD Date: Thu, 23 Apr 2026 05:06:34 +0300 Subject: [PATCH 31/79] 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 32/79] 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 33/79] 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 34/79] 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 35/79] 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 36/79] 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 37/79] 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 38/79] 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 39/79] 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 40/79] 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 41/79] [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 42/79] [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 43/79] [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 44/79] [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 45/79] [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 46/79] [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 47/79] 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 48/79] 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 49/79] 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 50/79] 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 51/79] [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 52/79] 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 53/79] 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 54/79] 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 55/79] [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 56/79] 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 57/79] [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 58/79] 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 c2f40e89d52346cb3edda4a11adc99eb1541dd07 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 23 Apr 2026 14:48:38 -0700 Subject: [PATCH 59/79] [Infra] Remove CCI/GHA test duplication and semantically shard proxy DB tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split into two related cleanups: 1. Delete CCI jobs that duplicate GHA coverage: - mcp_testing (tests/mcp_tests) — already run by test-mcp.yml - litellm_mapped_tests_proxy_part1/part2 (tests/test_litellm/proxy) — already run across test-unit-proxy-auth.yml, test-unit-proxy-endpoints.yml, and test-unit-proxy-infra.yml Add rag_endpoints and realtime_endpoints to test-unit-proxy-endpoints.yml (they were only covered by the deleted CCI part2 job). Remove the corresponding workflow wiring, coverage combine entries, and upload-coverage dependencies in .circleci/config.yml. 2. Re-shard test-unit-proxy-db.yml from 4 alphabetic buckets to 8 semantic ones (auth-and-jwt, proxy-server, logging-and-callbacks, db-and-spend, guardrails-budget-hooks, endpoints-and-responses, plus the existing serial key-generation and test_proxy_utils.py shards). New test files are placed in whichever group they belong to instead of reshuffling slices. Add a dist input to _test-unit-services-base.yml so the test_proxy_utils.py shard can use --dist=worksteal to spread its ~64 (many parametrized) functions across workers; the default --dist=loadscope pins a single file to a single worker, which was the root cause of that shard running 10m+. --- .circleci/config.yml | 77 +---------- .../workflows/_test-unit-services-base.yml | 8 +- .github/workflows/test-unit-proxy-db.yml | 122 ++++++++++++++++-- .../workflows/test-unit-proxy-endpoints.yml | 2 + 4 files changed, 120 insertions(+), 89 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index eabf4c6129..884ecbee0d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -563,39 +563,6 @@ jobs: paths: - realtime_translation_coverage.xml - realtime_translation_coverage - mcp_testing: - docker: - - *python312_image - working_directory: ~/project - - steps: - - checkout - - setup_google_dns - - install_uv - - run: - name: Install Dependencies - command: | - uv sync --frozen --all-groups --all-extras --python 3.12 - # Run pytest and generate JUnit XML report - - run: - name: Run tests - command: | - 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: - name: Rename the coverage files - command: | - mv coverage.xml mcp_coverage.xml - mv .coverage mcp_coverage - - # Store test results - - store_test_results: - path: test-results - - persist_to_workspace: - root: . - paths: - - mcp_coverage.xml - - mcp_coverage agent_testing: docker: - *python312_image @@ -794,39 +761,6 @@ jobs: paths: - search_coverage.xml - search_coverage - # Split litellm_mapped_tests into parallel jobs - litellm_mapped_tests_proxy_part1: - docker: - - *python312_image - working_directory: ~/project - resource_class: large - steps: - - setup_litellm_test_deps - - run: - name: Run proxy tests part 1 (high-volume directories) - command: | - uv run --no-sync python -m prisma generate - export PYTHONUNBUFFERED=1 - uv run --no-sync python -m pytest tests/test_litellm/proxy/guardrails tests/test_litellm/proxy/management_endpoints tests/test_litellm/proxy/_experimental tests/test_litellm/proxy/client tests/test_litellm/proxy/auth --junitxml=test-results/junit-proxy-part1.xml --durations=10 -n 4 --maxfail=5 --timeout=60 -vv --log-cli-level=WARNING -r A - no_output_timeout: 15m - - store_test_results: - path: test-results - litellm_mapped_tests_proxy_part2: - docker: - - *python312_image - working_directory: ~/project - resource_class: large - steps: - - setup_litellm_test_deps - - run: - name: Run proxy tests part 2 (all other tests) - command: | - uv run --no-sync python -m prisma generate - export PYTHONUNBUFFERED=1 - uv run --no-sync python -m pytest tests/test_litellm/proxy --ignore=tests/test_litellm/proxy/guardrails --ignore=tests/test_litellm/proxy/management_endpoints --ignore=tests/test_litellm/proxy/_experimental --ignore=tests/test_litellm/proxy/client --ignore=tests/test_litellm/proxy/auth --junitxml=test-results/junit-proxy-part2.xml --durations=10 -n 4 --maxfail=5 --timeout=120 -vv --log-cli-level=WARNING -r A - no_output_timeout: 15m - - store_test_results: - path: test-results litellm_mapped_enterprise_tests: docker: - *python312_image @@ -2072,7 +2006,7 @@ jobs: - run: name: Combine Coverage command: | - uv tool run --from 'coverage[toml]==7.10.6' coverage combine realtime_translation_coverage ocr_coverage search_coverage mcp_coverage logging_coverage audio_coverage local_testing_part1_coverage local_testing_part2_coverage pass_through_unit_tests_coverage batches_coverage guardrails_coverage redis_caching_coverage + uv tool run --from 'coverage[toml]==7.10.6' coverage combine realtime_translation_coverage ocr_coverage search_coverage logging_coverage audio_coverage local_testing_part1_coverage local_testing_part2_coverage pass_through_unit_tests_coverage batches_coverage guardrails_coverage redis_caching_coverage uv tool run --from 'coverage[toml]==7.10.6' coverage xml - codecov/upload: file: ./coverage.xml @@ -2407,8 +2341,6 @@ workflows: filters: *main_branches - realtime_translation_testing: filters: *main_branches - - mcp_testing: - filters: *main_branches - agent_testing: filters: *main_branches - guardrails_testing: @@ -2423,10 +2355,6 @@ workflows: filters: *main_branches - litellm_mapped_enterprise_tests: filters: *main_branches - - litellm_mapped_tests_proxy_part1: - filters: *main_branches - - litellm_mapped_tests_proxy_part2: - filters: *main_branches - batches_testing: filters: *main_branches - litellm_utils_testing: @@ -2444,14 +2372,11 @@ workflows: - upload-coverage: requires: - realtime_translation_testing - - mcp_testing - agent_testing - google_generate_content_endpoint_testing - guardrails_testing - ocr_testing - search_testing - - litellm_mapped_tests_proxy_part1 - - litellm_mapped_tests_proxy_part2 - litellm_mapped_enterprise_tests - batches_testing - litellm_utils_testing diff --git a/.github/workflows/_test-unit-services-base.yml b/.github/workflows/_test-unit-services-base.yml index 8e0b3568ae..9de3ac3cf5 100644 --- a/.github/workflows/_test-unit-services-base.yml +++ b/.github/workflows/_test-unit-services-base.yml @@ -32,6 +32,11 @@ on: required: false type: boolean default: false + dist: + description: "pytest-xdist distribution mode (loadscope|load|worksteal|loadfile|no)" + required: false + type: string + default: "loadscope" artifact-name: description: "Unique name for the coverage artifact (must be unique per run)" required: false @@ -124,6 +129,7 @@ jobs: MAX_FAILURES: ${{ inputs.max-failures }} WORKERS: ${{ inputs.workers }} RERUNS: ${{ inputs.reruns }} + DIST: ${{ inputs.dist }} DATABASE_URL: ${{ inputs.enable-postgres && secrets.DATABASE_URL || '' }} run: | if [ "${WORKERS}" = "0" ]; then @@ -143,7 +149,7 @@ jobs: -n "${WORKERS}" \ --reruns "${RERUNS}" \ --reruns-delay 1 \ - --dist=loadscope \ + --dist="${DIST}" \ --durations=20 \ --cov=litellm \ --cov-report=xml:coverage.xml \ diff --git a/.github/workflows/test-unit-proxy-db.yml b/.github/workflows/test-unit-proxy-db.yml index a631a7c300..f8d5bc265a 100644 --- a/.github/workflows/test-unit-proxy-db.yml +++ b/.github/workflows/test-unit-proxy-db.yml @@ -12,6 +12,18 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +# Semantic matrix: each shard groups tests by concern (auth, server, logging, …) +# rather than alphabetical letter ranges. Adding a new test file means adding it +# to whichever group it belongs to, not reshuffling slices. +# +# Design targets: +# * Every shard runs in <= 7 minutes on a 4-core runner. +# * test_key_generate_prisma.py stays serial (workers=0) — it has event-loop +# conflicts with the logging worker when run in parallel. +# * test_proxy_utils.py runs in its own shard with --dist=worksteal so xdist +# spreads its ~64 functions (many parametrized) across workers instead of +# pinning the whole file to a single worker (the default --dist=loadscope +# behavior for single-file targets). jobs: proxy-db: permissions: @@ -22,26 +34,111 @@ jobs: fail-fast: false matrix: include: - # Key generation tests must NOT run in parallel (event loop conflicts with logging worker) + # Must run serially — event-loop conflict with the logging worker. - test-group: key-generation test-path: "tests/proxy_unit_tests/test_key_generate_prisma.py" workers: 0 - timeout: 30 - - test-group: auth-checks - test-path: "tests/proxy_unit_tests/test_auth_checks.py tests/proxy_unit_tests/test_user_api_key_auth.py" - workers: 8 + dist: loadscope timeout: 20 - # test_proxy_utils.py is large (168+ parametrized tests) — run it on its - # own matrix so --dist=loadscope doesn't pin all of it to a single xdist - # worker and push the "remaining" group past the job timeout. + + - test-group: auth-and-jwt + test-path: >- + tests/proxy_unit_tests/test_auth_checks.py + tests/proxy_unit_tests/test_user_api_key_auth.py + tests/proxy_unit_tests/test_jwt.py + tests/proxy_unit_tests/test_jwt_key_mapping.py + tests/proxy_unit_tests/test_proxy_custom_auth.py + tests/proxy_unit_tests/test_key_generate_dynamodb.py + tests/proxy_unit_tests/test_deployed_proxy_keygen.py + workers: 8 + dist: loadscope + timeout: 15 + + # Own shard, --dist=worksteal so parametrized cases fan out across workers. - test-group: proxy-utils test-path: "tests/proxy_unit_tests/test_proxy_utils.py" workers: 8 - timeout: 20 - - test-group: remaining - test-path: "tests/proxy_unit_tests --ignore=tests/proxy_unit_tests/test_key_generate_prisma.py --ignore=tests/proxy_unit_tests/test_auth_checks.py --ignore=tests/proxy_unit_tests/test_user_api_key_auth.py --ignore=tests/proxy_unit_tests/test_proxy_utils.py" + dist: worksteal + timeout: 15 + + - test-group: proxy-server + test-path: >- + tests/proxy_unit_tests/test_proxy_server.py + tests/proxy_unit_tests/test_proxy_server_keys.py + tests/proxy_unit_tests/test_proxy_server_caching.py + tests/proxy_unit_tests/test_proxy_server_langfuse.py + tests/proxy_unit_tests/test_proxy_server_spend.py + tests/proxy_unit_tests/test_aproxy_startup.py + tests/proxy_unit_tests/test_proxy_config_unit_test.py + tests/proxy_unit_tests/test_proxy_routes.py + tests/proxy_unit_tests/test_proxy_gunicorn.py + tests/proxy_unit_tests/test_server_root_path.py + tests/proxy_unit_tests/test_proxy_pass_user_config.py + tests/proxy_unit_tests/test_proxy_token_counter.py workers: 8 - timeout: 30 + dist: loadscope + timeout: 15 + + - test-group: logging-and-callbacks + test-path: >- + tests/proxy_unit_tests/test_custom_callback_input.py + tests/proxy_unit_tests/test_custom_logger_s3_gcs.py + tests/proxy_unit_tests/test_proxy_custom_logger.py + tests/proxy_unit_tests/test_proxy_reject_logging.py + tests/proxy_unit_tests/test_audit_logs_proxy.py + tests/proxy_unit_tests/test_search_api_logging.py + workers: 8 + dist: loadscope + timeout: 15 + + - test-group: db-and-spend + test-path: >- + tests/proxy_unit_tests/test_prisma_client_backoff_retry.py + tests/proxy_unit_tests/test_db_schema_changes.py + tests/proxy_unit_tests/test_db_schema_migration.py + tests/proxy_unit_tests/test_e2e_pod_lock_manager.py + tests/proxy_unit_tests/test_skills_db.py + tests/proxy_unit_tests/test_update_daily_tag_spend.py + tests/proxy_unit_tests/test_update_spend.py + tests/proxy_unit_tests/test_project_endpoints_prisma.py + tests/proxy_unit_tests/test_proxy_encrypt_decrypt.py + workers: 8 + dist: loadscope + timeout: 15 + + - test-group: guardrails-budget-hooks + test-path: >- + tests/proxy_unit_tests/test_proxy_setting_guardrails.py + tests/proxy_unit_tests/test_banned_keyword_list.py + tests/proxy_unit_tests/test_default_end_user_budget_simple.py + tests/proxy_unit_tests/test_unit_test_max_model_budget_limiter.py + tests/proxy_unit_tests/test_zero_cost_model_budget_bypass.py + tests/proxy_unit_tests/test_unit_test_proxy_hooks.py + workers: 8 + dist: loadscope + timeout: 15 + + - test-group: endpoints-and-responses + test-path: >- + tests/proxy_unit_tests/test_blog_posts_endpoint.py + tests/proxy_unit_tests/test_models_fallback_endpoint.py + tests/proxy_unit_tests/test_google_endpoint_routing.py + tests/proxy_unit_tests/test_google_gemini_proxy_request.py + tests/proxy_unit_tests/test_get_favicon.py + tests/proxy_unit_tests/test_get_image.py + tests/proxy_unit_tests/test_ui_path_detection.py + tests/proxy_unit_tests/test_prompt_test_endpoint.py + tests/proxy_unit_tests/test_check_batch_cost.py + tests/proxy_unit_tests/test_check_responses_cost.py + tests/proxy_unit_tests/test_response_polling_handler.py + tests/proxy_unit_tests/test_response_polling_pre_call_checks.py + tests/proxy_unit_tests/test_realtime_cache.py + tests/proxy_unit_tests/test_proxy_exception_mapping.py + tests/proxy_unit_tests/test_custom_tokenizer_bug.py + tests/proxy_unit_tests/test_model_response_typing + workers: 8 + dist: loadscope + timeout: 15 uses: ./.github/workflows/_test-unit-services-base.yml with: test-path: ${{ matrix.test-path }} @@ -49,6 +146,7 @@ jobs: reruns: 2 timeout-minutes: ${{ matrix.timeout }} enable-postgres: true + dist: ${{ matrix.dist }} artifact-name: proxy-db-${{ matrix.test-group }} secrets: DATABASE_URL: ${{ secrets.DATABASE_URL }} diff --git a/.github/workflows/test-unit-proxy-endpoints.yml b/.github/workflows/test-unit-proxy-endpoints.yml index fafc866a3f..1439b2c07f 100644 --- a/.github/workflows/test-unit-proxy-endpoints.yml +++ b/.github/workflows/test-unit-proxy-endpoints.yml @@ -36,6 +36,8 @@ jobs: tests/test_litellm/proxy/health_endpoints tests/test_litellm/proxy/public_endpoints tests/test_litellm/proxy/prompts + tests/test_litellm/proxy/rag_endpoints + tests/test_litellm/proxy/realtime_endpoints tests/test_litellm/proxy/ui_crud_endpoints workers: 2 reruns: 2 From 32c390a0f6afd3acc85aed4492aa154d6a2d401d Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 23 Apr 2026 15:01:25 -0700 Subject: [PATCH 60/79] fix(tests): restore proxy_server.master_key in realtime fixture; add shard-coverage guard Two fixes to proxy-db CI: 1. test_realtime_webrtc_endpoints.py's `proxy_app` fixture mutated the module-global `proxy_server.master_key` without restoring it, leaking state into any test that shared the same xdist worker. Under --dist=loadscope with 2 workers (GHA proxy-endpoints), this caused the google_endpoints tests to fail with "No api key passed in." because user_api_key_auth saw a set master_key and a missing API key on the test request. The fixture now saves and restores the original value. 2. Address the Greptile note that the semantic shard design has no catch-all, so a new test file added to tests/proxy_unit_tests/ without a matrix entry would silently skip CI. Adds an assert-shard-coverage job that enumerates test_*.py files and fails the workflow if any are not referenced by a matrix entry, with a clear message telling the author which semantic shard to place it in. All proxy-db shards now depend on this guard. --- .github/workflows/test-unit-proxy-db.yml | 43 +++++++++++++++++++ .../test_realtime_webrtc_endpoints.py | 10 ++++- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test-unit-proxy-db.yml b/.github/workflows/test-unit-proxy-db.yml index f8d5bc265a..0f2694984e 100644 --- a/.github/workflows/test-unit-proxy-db.yml +++ b/.github/workflows/test-unit-proxy-db.yml @@ -25,7 +25,50 @@ concurrency: # pinning the whole file to a single worker (the default --dist=loadscope # behavior for single-file targets). jobs: + # Fast guard — fails the workflow if a test_*.py file under + # tests/proxy_unit_tests/ is not referenced by any matrix entry below. + # The semantic-shard design (no catch-all "remaining" bucket) relies on + # every test file being explicitly assigned; this guard prevents a new + # file from silently dropping out of CI. + assert-shard-coverage: + runs-on: ubuntu-latest + timeout-minutes: 2 + permissions: + contents: read + steps: + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + persist-credentials: false + - name: Assert every test_*.py is in a matrix shard + run: | + python3 - <<'PY' + import pathlib, re, sys, yaml + wf = yaml.safe_load(open(".github/workflows/test-unit-proxy-db.yml")) + matrix = wf["jobs"]["proxy-db"]["strategy"]["matrix"]["include"] + referenced = set() + for entry in matrix: + for token in entry["test-path"].split(): + if token.startswith("tests/proxy_unit_tests/"): + referenced.add(pathlib.PurePosixPath(token).name) + actual = {p.name for p in pathlib.Path("tests/proxy_unit_tests").iterdir() + if p.name.startswith("test_") and (p.suffix == ".py" or p.is_dir()) + and p.name != "test_configs"} + orphans = sorted(actual - referenced) + if orphans: + print("ERROR: the following files/dirs under tests/proxy_unit_tests/") + print(" are not assigned to any shard in test-unit-proxy-db.yml:") + for o in orphans: + print(f" - {o}") + print() + print("Add each to whichever semantic shard (auth-and-jwt, proxy-server,") + print("logging-and-callbacks, db-and-spend, guardrails-budget-hooks,") + print("endpoints-and-responses, proxy-utils, key-generation) it belongs to.") + sys.exit(1) + print(f"OK: all {len(actual)} files assigned to a shard.") + PY + proxy-db: + needs: assert-shard-coverage permissions: contents: read id-token: write diff --git a/tests/test_litellm/proxy/realtime_endpoints/test_realtime_webrtc_endpoints.py b/tests/test_litellm/proxy/realtime_endpoints/test_realtime_webrtc_endpoints.py index e414f975f5..99b6335ce8 100644 --- a/tests/test_litellm/proxy/realtime_endpoints/test_realtime_webrtc_endpoints.py +++ b/tests/test_litellm/proxy/realtime_endpoints/test_realtime_webrtc_endpoints.py @@ -116,8 +116,16 @@ def test_decode_realtime_token_payload_ephemeral_key_not_string(): def proxy_app(): from litellm.proxy import proxy_server + # master_key is a module-global — restore it on teardown so this fixture + # doesn't leak state into unrelated tests that share the same xdist worker + # (e.g. tests that assume master_key is None and send unauthenticated + # requests to the shared FastAPI app). + original_master_key = proxy_server.master_key proxy_server.master_key = "sk-test-master-key" - return proxy_server.app + try: + yield proxy_server.app + finally: + proxy_server.master_key = original_master_key @pytest.fixture From c14a73fa59138eaf86c268c1b1879f57d9c7689c Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 23 Apr 2026 15:06:33 -0700 Subject: [PATCH 61/79] 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 62/79] [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 e0201ece1ed854eb317b88620938a0db63e75ac3 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 23 Apr 2026 15:25:37 -0700 Subject: [PATCH 63/79] [Infra] Split slow proxy-db shards to hit 7m wall-clock target Previous run (13.8m total) was bottlenecked by shards with 9-12m wall-clock. Setup + xdist spawn + coverage teardown is ~3m per shard, so each shard's pytest runtime must stay under ~4m to fit inside 7m total. Observed per-shard pytest times (before split): db-and-spend 9:08 (170s outlier: test_aaaasschema_migration_check) proxy-server 7:15 logging-and-callbacks 6:45 guardrails-budget-hooks 6:37 proxy-utils 6:23 auth-and-jwt 6:54 Split 6 shards into 12, keeping key-generation and endpoints-and-responses (already <7m). Adds a `keyword` input to _test-unit-services-base.yml so test_proxy_utils.py can be split by -k expression (same file, two runners). New matrix entries: auth-and-jwt -> auth-checks + jwt-and-keys proxy-server -> proxy-server-core + proxy-runtime logging-and-callbacks -> custom-logging + logging-misc db-and-spend -> schema-migration (isolated 170s test) + db-and-spend guardrails-budget-hooks-> guardrails-hooks + budgets proxy-utils -> proxy-utils-a-h + proxy-utils-i-z (-k split) The -k expression split is verified to cover every one of the 64 test functions in test_proxy_utils.py exactly once. The assert-shard-coverage guard still catches any file not in any shard. --- .../workflows/_test-unit-services-base.yml | 18 ++- .github/workflows/test-unit-proxy-db.yml | 110 +++++++++++++++--- 2 files changed, 107 insertions(+), 21 deletions(-) diff --git a/.github/workflows/_test-unit-services-base.yml b/.github/workflows/_test-unit-services-base.yml index 9de3ac3cf5..766516d266 100644 --- a/.github/workflows/_test-unit-services-base.yml +++ b/.github/workflows/_test-unit-services-base.yml @@ -37,6 +37,11 @@ on: required: false type: string default: "loadscope" + keyword: + description: "Optional pytest -k expression to filter tests (e.g. 'test_a or test_c')" + required: false + type: string + default: "" artifact-name: description: "Unique name for the coverage artifact (must be unique per run)" required: false @@ -130,8 +135,15 @@ jobs: WORKERS: ${{ inputs.workers }} RERUNS: ${{ inputs.reruns }} DIST: ${{ inputs.dist }} + KEYWORD: ${{ inputs.keyword }} DATABASE_URL: ${{ inputs.enable-postgres && secrets.DATABASE_URL || '' }} run: | + # Build optional -k filter as an array so expressions with spaces + # (e.g. "test_a or test_b") stay a single argv entry to pytest. + K_ARGS=() + if [ -n "${KEYWORD}" ]; then + K_ARGS=(-k "${KEYWORD}") + fi if [ "${WORKERS}" = "0" ]; then uv run --no-sync pytest ${TEST_PATH:?} \ --tb=short -vv \ @@ -141,7 +153,8 @@ jobs: --durations=20 \ --cov=litellm \ --cov-report=xml:coverage.xml \ - --cov-config=pyproject.toml + --cov-config=pyproject.toml \ + "${K_ARGS[@]}" else uv run --no-sync pytest ${TEST_PATH:?} \ --tb=short -vv \ @@ -153,7 +166,8 @@ jobs: --durations=20 \ --cov=litellm \ --cov-report=xml:coverage.xml \ - --cov-config=pyproject.toml + --cov-config=pyproject.toml \ + "${K_ARGS[@]}" fi - name: Save coverage report diff --git a/.github/workflows/test-unit-proxy-db.yml b/.github/workflows/test-unit-proxy-db.yml index 0f2694984e..b6b6fc0368 100644 --- a/.github/workflows/test-unit-proxy-db.yml +++ b/.github/workflows/test-unit-proxy-db.yml @@ -17,13 +17,20 @@ concurrency: # to whichever group it belongs to, not reshuffling slices. # # Design targets: -# * Every shard runs in <= 7 minutes on a 4-core runner. +# * Every shard runs in <= 7 minutes of wall-clock on the default runner. +# Setup + xdist worker spawn + coverage teardown is ~3 minutes per shard, +# so each shard's pytest runtime must stay under ~4 minutes. That drives +# the split granularity: shards get subdivided when pytest call time +# exceeds ~4m or any single test exceeds ~3m (it pins one xdist worker). # * test_key_generate_prisma.py stays serial (workers=0) — it has event-loop # conflicts with the logging worker when run in parallel. -# * test_proxy_utils.py runs in its own shard with --dist=worksteal so xdist -# spreads its ~64 functions (many parametrized) across workers instead of -# pinning the whole file to a single worker (the default --dist=loadscope -# behavior for single-file targets). +# * test_proxy_utils.py is split into two -k-filtered shards (by first +# character of the test function name) so its 188 parametrized cases +# fan out across two runners rather than one. --dist=worksteal within +# each shard balances parametrized cases across xdist workers. +# * test_db_schema_migration.py is isolated because one test in it +# (test_aaaasschema_migration_check) takes ~170s — by itself it +# determines the shard's wall-clock floor. jobs: # Fast guard — fails the workflow if a test_*.py file under # tests/proxy_unit_tests/ is not referenced by any matrix entry below. @@ -42,7 +49,7 @@ jobs: - name: Assert every test_*.py is in a matrix shard run: | python3 - <<'PY' - import pathlib, re, sys, yaml + import pathlib, sys, yaml wf = yaml.safe_load(open(".github/workflows/test-unit-proxy-db.yml")) matrix = wf["jobs"]["proxy-db"]["strategy"]["matrix"]["include"] referenced = set() @@ -60,9 +67,7 @@ jobs: for o in orphans: print(f" - {o}") print() - print("Add each to whichever semantic shard (auth-and-jwt, proxy-server,") - print("logging-and-callbacks, db-and-spend, guardrails-budget-hooks,") - print("endpoints-and-responses, proxy-utils, key-generation) it belongs to.") + print("Add each to whichever semantic shard it belongs to.") sys.exit(1) print(f"OK: all {len(actual)} files assigned to a shard.") PY @@ -82,12 +87,20 @@ jobs: test-path: "tests/proxy_unit_tests/test_key_generate_prisma.py" workers: 0 dist: loadscope + keyword: "" timeout: 20 - - test-group: auth-and-jwt + # ---- auth: split into 2 shards (was 1 at ~10.4m wall-clock) ---- + - test-group: auth-checks test-path: >- tests/proxy_unit_tests/test_auth_checks.py tests/proxy_unit_tests/test_user_api_key_auth.py + workers: 8 + dist: loadscope + keyword: "" + timeout: 15 + - test-group: jwt-and-keys + test-path: >- tests/proxy_unit_tests/test_jwt.py tests/proxy_unit_tests/test_jwt_key_mapping.py tests/proxy_unit_tests/test_proxy_custom_auth.py @@ -95,16 +108,38 @@ jobs: tests/proxy_unit_tests/test_deployed_proxy_keygen.py workers: 8 dist: loadscope + keyword: "" timeout: 15 - # Own shard, --dist=worksteal so parametrized cases fan out across workers. - - test-group: proxy-utils + # ---- test_proxy_utils.py split into 2 by -k (was 1 at ~9.7m) ---- + # Same file, same --dist=worksteal, filtered by first char of test + # function name. Keywords below cover all 63 test functions in the + # file. If new functions are added, balance between the two shards. + - test-group: proxy-utils-a-h test-path: "tests/proxy_unit_tests/test_proxy_utils.py" workers: 8 dist: worksteal + keyword: >- + test_add or test_check or test_custom or test_during or + test_dynamic or test_end_user or test_enforced or test_foward or + test_get_admin or test_get_complete or test_get_docs or + test_get_known or test_get_model_group or test_get_openapi or + test_get_redoc or test_get_temp or test_get_user_info or + test_handle or test_health + timeout: 15 + - test-group: proxy-utils-i-z + test-path: "tests/proxy_unit_tests/test_proxy_utils.py" + workers: 8 + dist: worksteal + keyword: >- + test_is or test_litellm or test_merge or test_post_call or + test_prepare or test_provider or test_proxy_config or + test_reading or test_spend or test_team or test_traceparent or + test_update or test_get_key or test_get_team timeout: 15 - - test-group: proxy-server + # ---- proxy server: split into 2 shards (was 1 at ~11.1m) ---- + - test-group: proxy-server-core test-path: >- tests/proxy_unit_tests/test_proxy_server.py tests/proxy_unit_tests/test_proxy_server_keys.py @@ -112,6 +147,12 @@ jobs: tests/proxy_unit_tests/test_proxy_server_langfuse.py tests/proxy_unit_tests/test_proxy_server_spend.py tests/proxy_unit_tests/test_aproxy_startup.py + workers: 8 + dist: loadscope + keyword: "" + timeout: 15 + - test-group: proxy-runtime + test-path: >- tests/proxy_unit_tests/test_proxy_config_unit_test.py tests/proxy_unit_tests/test_proxy_routes.py tests/proxy_unit_tests/test_proxy_gunicorn.py @@ -120,25 +161,44 @@ jobs: tests/proxy_unit_tests/test_proxy_token_counter.py workers: 8 dist: loadscope + keyword: "" timeout: 15 - - test-group: logging-and-callbacks + # ---- logging: split into 2 shards (was 1 at ~10.1m) ---- + - test-group: custom-logging test-path: >- tests/proxy_unit_tests/test_custom_callback_input.py tests/proxy_unit_tests/test_custom_logger_s3_gcs.py tests/proxy_unit_tests/test_proxy_custom_logger.py + workers: 8 + dist: loadscope + keyword: "" + timeout: 15 + - test-group: logging-misc + test-path: >- tests/proxy_unit_tests/test_proxy_reject_logging.py tests/proxy_unit_tests/test_audit_logs_proxy.py tests/proxy_unit_tests/test_search_api_logging.py workers: 8 dist: loadscope + keyword: "" timeout: 15 + # ---- db-and-spend: split out the 170s schema-migration test ---- + # test_db_schema_migration.py has one test that runs ~170s; it + # single-handedly pins one xdist worker and determined the whole + # shard's 12.3m wall-clock. Isolated here so the other 45 tests + # finish faster. + - test-group: schema-migration + test-path: "tests/proxy_unit_tests/test_db_schema_migration.py" + workers: 8 + dist: loadscope + keyword: "" + timeout: 15 - test-group: db-and-spend test-path: >- tests/proxy_unit_tests/test_prisma_client_backoff_retry.py tests/proxy_unit_tests/test_db_schema_changes.py - tests/proxy_unit_tests/test_db_schema_migration.py tests/proxy_unit_tests/test_e2e_pod_lock_manager.py tests/proxy_unit_tests/test_skills_db.py tests/proxy_unit_tests/test_update_daily_tag_spend.py @@ -147,20 +207,30 @@ jobs: tests/proxy_unit_tests/test_proxy_encrypt_decrypt.py workers: 8 dist: loadscope + keyword: "" timeout: 15 - - test-group: guardrails-budget-hooks + # ---- guardrails + budget + hooks: split into 2 (was 1 at ~10.1m) ---- + - test-group: guardrails-hooks test-path: >- tests/proxy_unit_tests/test_proxy_setting_guardrails.py tests/proxy_unit_tests/test_banned_keyword_list.py - tests/proxy_unit_tests/test_default_end_user_budget_simple.py - tests/proxy_unit_tests/test_unit_test_max_model_budget_limiter.py - tests/proxy_unit_tests/test_zero_cost_model_budget_bypass.py tests/proxy_unit_tests/test_unit_test_proxy_hooks.py workers: 8 dist: loadscope + keyword: "" + timeout: 15 + - test-group: budgets + test-path: >- + tests/proxy_unit_tests/test_default_end_user_budget_simple.py + tests/proxy_unit_tests/test_unit_test_max_model_budget_limiter.py + tests/proxy_unit_tests/test_zero_cost_model_budget_bypass.py + workers: 8 + dist: loadscope + keyword: "" timeout: 15 + # Already under 7m; left as a single shard. - test-group: endpoints-and-responses test-path: >- tests/proxy_unit_tests/test_blog_posts_endpoint.py @@ -181,6 +251,7 @@ jobs: tests/proxy_unit_tests/test_model_response_typing workers: 8 dist: loadscope + keyword: "" timeout: 15 uses: ./.github/workflows/_test-unit-services-base.yml with: @@ -190,6 +261,7 @@ jobs: timeout-minutes: ${{ matrix.timeout }} enable-postgres: true dist: ${{ matrix.dist }} + keyword: ${{ matrix.keyword }} artifact-name: proxy-db-${{ matrix.test-group }} secrets: DATABASE_URL: ${{ secrets.DATABASE_URL }} From 584a7cd40fc6e7250df06aaa6c415d10d5329a96 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 23 Apr 2026 15:29:42 -0700 Subject: [PATCH 64/79] [Infra] Clean up proxy-db matrix job display names Default GHA matrix job names join every matrix field, producing unreadable check labels like: 'proxy-db (logging-misc, tests/proxy_unit_tests/test_proxy_reject_logging.py tests/proxy_unit_tests/test_audit_logs_proxy.py ..., 8, loadscope, "", 15)' Set the job's display name to '${{ matrix.test-group }}' so each check shows just 'logging-misc', 'proxy-utils-a-h', etc. --- .github/workflows/test-unit-proxy-db.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/test-unit-proxy-db.yml b/.github/workflows/test-unit-proxy-db.yml index b6b6fc0368..17b564d5b7 100644 --- a/.github/workflows/test-unit-proxy-db.yml +++ b/.github/workflows/test-unit-proxy-db.yml @@ -74,6 +74,10 @@ jobs: proxy-db: needs: assert-shard-coverage + # Display only the semantic shard name in the checks UI instead of GHA's + # default "proxy-db (key-generation, tests/proxy_unit_tests/…, 0, loadscope, "", 20)" + # which includes every matrix field and gets truncated past the test-path. + name: ${{ matrix.test-group }} permissions: contents: read id-token: write From 5df9f397e6d97c0f523f12104ef5e4ec901dafef Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 23 Apr 2026 15:56:27 -0700 Subject: [PATCH 65/79] [Infra] Match xdist workers to runner cores; revert test_proxy_utils -k split Two changes: 1. workers: 8 -> 4 on every non-serial proxy-db shard. ubuntu-latest is a 4-core runner; -n 8 oversubscribes 2x and workers block each other during their cold-start imports (pytest-cov instruments every litellm module per worker). Measured ~441% CPU locally with -n 8 on 8 cores (i.e. ~55% effective). Matching -n to physical cores should give ~2x faster worker startup, which is where most of the ~9m wall-clock per shard goes (7+ minutes is plugin load + xdist imports before any test runs). 2. Revert the -k split on test_proxy_utils.py. It was split into proxy-utils-a-h / proxy-utils-i-z as a semantic-adjacent hack; merge back to a single proxy-utils shard. Still uses --dist=worksteal so xdist can balance the 188 parametrized cases across workers. Also drops the now-unused `keyword` input from _test-unit-services-base.yml and its matching matrix field across all proxy-db entries. Shard count: 14 -> 13 (+ the assert-shard-coverage guard). --- .../workflows/_test-unit-services-base.yml | 18 +--- .github/workflows/test-unit-proxy-db.yml | 92 ++++++------------- 2 files changed, 32 insertions(+), 78 deletions(-) diff --git a/.github/workflows/_test-unit-services-base.yml b/.github/workflows/_test-unit-services-base.yml index 766516d266..9de3ac3cf5 100644 --- a/.github/workflows/_test-unit-services-base.yml +++ b/.github/workflows/_test-unit-services-base.yml @@ -37,11 +37,6 @@ on: required: false type: string default: "loadscope" - keyword: - description: "Optional pytest -k expression to filter tests (e.g. 'test_a or test_c')" - required: false - type: string - default: "" artifact-name: description: "Unique name for the coverage artifact (must be unique per run)" required: false @@ -135,15 +130,8 @@ jobs: WORKERS: ${{ inputs.workers }} RERUNS: ${{ inputs.reruns }} DIST: ${{ inputs.dist }} - KEYWORD: ${{ inputs.keyword }} DATABASE_URL: ${{ inputs.enable-postgres && secrets.DATABASE_URL || '' }} run: | - # Build optional -k filter as an array so expressions with spaces - # (e.g. "test_a or test_b") stay a single argv entry to pytest. - K_ARGS=() - if [ -n "${KEYWORD}" ]; then - K_ARGS=(-k "${KEYWORD}") - fi if [ "${WORKERS}" = "0" ]; then uv run --no-sync pytest ${TEST_PATH:?} \ --tb=short -vv \ @@ -153,8 +141,7 @@ jobs: --durations=20 \ --cov=litellm \ --cov-report=xml:coverage.xml \ - --cov-config=pyproject.toml \ - "${K_ARGS[@]}" + --cov-config=pyproject.toml else uv run --no-sync pytest ${TEST_PATH:?} \ --tb=short -vv \ @@ -166,8 +153,7 @@ jobs: --durations=20 \ --cov=litellm \ --cov-report=xml:coverage.xml \ - --cov-config=pyproject.toml \ - "${K_ARGS[@]}" + --cov-config=pyproject.toml fi - name: Save coverage report diff --git a/.github/workflows/test-unit-proxy-db.yml b/.github/workflows/test-unit-proxy-db.yml index 17b564d5b7..b9496e39a3 100644 --- a/.github/workflows/test-unit-proxy-db.yml +++ b/.github/workflows/test-unit-proxy-db.yml @@ -18,16 +18,18 @@ concurrency: # # Design targets: # * Every shard runs in <= 7 minutes of wall-clock on the default runner. -# Setup + xdist worker spawn + coverage teardown is ~3 minutes per shard, -# so each shard's pytest runtime must stay under ~4 minutes. That drives -# the split granularity: shards get subdivided when pytest call time -# exceeds ~4m or any single test exceeds ~3m (it pins one xdist worker). +# Most of a shard's time is pytest plugin load + xdist worker imports + +# pytest-cov instrumentation, not the tests themselves. Keeping per-shard +# work low and matching worker count to runner cores is what controls it. +# * workers: 4 matches the 4-core ubuntu-latest runner. -n 8 on 4 cores +# oversubscribes 2x and workers fight for CPU during their cold-start +# imports (measured ~441% CPU for -n 8 locally, i.e. ~55% effective). # * test_key_generate_prisma.py stays serial (workers=0) — it has event-loop # conflicts with the logging worker when run in parallel. -# * test_proxy_utils.py is split into two -k-filtered shards (by first -# character of the test function name) so its 188 parametrized cases -# fan out across two runners rather than one. --dist=worksteal within -# each shard balances parametrized cases across xdist workers. +# * test_proxy_utils.py runs as a single shard with --dist=worksteal so +# xdist balances its 188 parametrized cases across workers instead of +# pinning the whole file to one worker (the default --dist=loadscope +# behavior for single-file targets). # * test_db_schema_migration.py is isolated because one test in it # (test_aaaasschema_migration_check) takes ~170s — by itself it # determines the shard's wall-clock floor. @@ -75,7 +77,7 @@ jobs: proxy-db: needs: assert-shard-coverage # Display only the semantic shard name in the checks UI instead of GHA's - # default "proxy-db (key-generation, tests/proxy_unit_tests/…, 0, loadscope, "", 20)" + # default "proxy-db (key-generation, tests/proxy_unit_tests/…, 0, loadscope, 20)" # which includes every matrix field and gets truncated past the test-path. name: ${{ matrix.test-group }} permissions: @@ -91,17 +93,15 @@ jobs: test-path: "tests/proxy_unit_tests/test_key_generate_prisma.py" workers: 0 dist: loadscope - keyword: "" timeout: 20 - # ---- auth: split into 2 shards (was 1 at ~10.4m wall-clock) ---- + # ---- auth: split into 2 shards ---- - test-group: auth-checks test-path: >- tests/proxy_unit_tests/test_auth_checks.py tests/proxy_unit_tests/test_user_api_key_auth.py - workers: 8 + workers: 4 dist: loadscope - keyword: "" timeout: 15 - test-group: jwt-and-keys test-path: >- @@ -110,39 +110,18 @@ jobs: tests/proxy_unit_tests/test_proxy_custom_auth.py tests/proxy_unit_tests/test_key_generate_dynamodb.py tests/proxy_unit_tests/test_deployed_proxy_keygen.py - workers: 8 + workers: 4 dist: loadscope - keyword: "" timeout: 15 - # ---- test_proxy_utils.py split into 2 by -k (was 1 at ~9.7m) ---- - # Same file, same --dist=worksteal, filtered by first char of test - # function name. Keywords below cover all 63 test functions in the - # file. If new functions are added, balance between the two shards. - - test-group: proxy-utils-a-h + # ---- test_proxy_utils.py, single shard, worksteal distribution ---- + - test-group: proxy-utils test-path: "tests/proxy_unit_tests/test_proxy_utils.py" - workers: 8 + workers: 4 dist: worksteal - keyword: >- - test_add or test_check or test_custom or test_during or - test_dynamic or test_end_user or test_enforced or test_foward or - test_get_admin or test_get_complete or test_get_docs or - test_get_known or test_get_model_group or test_get_openapi or - test_get_redoc or test_get_temp or test_get_user_info or - test_handle or test_health - timeout: 15 - - test-group: proxy-utils-i-z - test-path: "tests/proxy_unit_tests/test_proxy_utils.py" - workers: 8 - dist: worksteal - keyword: >- - test_is or test_litellm or test_merge or test_post_call or - test_prepare or test_provider or test_proxy_config or - test_reading or test_spend or test_team or test_traceparent or - test_update or test_get_key or test_get_team timeout: 15 - # ---- proxy server: split into 2 shards (was 1 at ~11.1m) ---- + # ---- proxy server: split into 2 shards ---- - test-group: proxy-server-core test-path: >- tests/proxy_unit_tests/test_proxy_server.py @@ -151,9 +130,8 @@ jobs: tests/proxy_unit_tests/test_proxy_server_langfuse.py tests/proxy_unit_tests/test_proxy_server_spend.py tests/proxy_unit_tests/test_aproxy_startup.py - workers: 8 + workers: 4 dist: loadscope - keyword: "" timeout: 15 - test-group: proxy-runtime test-path: >- @@ -163,41 +141,37 @@ jobs: tests/proxy_unit_tests/test_server_root_path.py tests/proxy_unit_tests/test_proxy_pass_user_config.py tests/proxy_unit_tests/test_proxy_token_counter.py - workers: 8 + workers: 4 dist: loadscope - keyword: "" timeout: 15 - # ---- logging: split into 2 shards (was 1 at ~10.1m) ---- + # ---- logging: split into 2 shards ---- - test-group: custom-logging test-path: >- tests/proxy_unit_tests/test_custom_callback_input.py tests/proxy_unit_tests/test_custom_logger_s3_gcs.py tests/proxy_unit_tests/test_proxy_custom_logger.py - workers: 8 + workers: 4 dist: loadscope - keyword: "" timeout: 15 - test-group: logging-misc test-path: >- tests/proxy_unit_tests/test_proxy_reject_logging.py tests/proxy_unit_tests/test_audit_logs_proxy.py tests/proxy_unit_tests/test_search_api_logging.py - workers: 8 + workers: 4 dist: loadscope - keyword: "" timeout: 15 - # ---- db-and-spend: split out the 170s schema-migration test ---- + # ---- db-and-spend: isolate the 170s schema-migration test ---- # test_db_schema_migration.py has one test that runs ~170s; it # single-handedly pins one xdist worker and determined the whole # shard's 12.3m wall-clock. Isolated here so the other 45 tests # finish faster. - test-group: schema-migration test-path: "tests/proxy_unit_tests/test_db_schema_migration.py" - workers: 8 + workers: 4 dist: loadscope - keyword: "" timeout: 15 - test-group: db-and-spend test-path: >- @@ -209,32 +183,28 @@ jobs: tests/proxy_unit_tests/test_update_spend.py tests/proxy_unit_tests/test_project_endpoints_prisma.py tests/proxy_unit_tests/test_proxy_encrypt_decrypt.py - workers: 8 + workers: 4 dist: loadscope - keyword: "" timeout: 15 - # ---- guardrails + budget + hooks: split into 2 (was 1 at ~10.1m) ---- + # ---- guardrails + budget + hooks: split into 2 ---- - test-group: guardrails-hooks test-path: >- tests/proxy_unit_tests/test_proxy_setting_guardrails.py tests/proxy_unit_tests/test_banned_keyword_list.py tests/proxy_unit_tests/test_unit_test_proxy_hooks.py - workers: 8 + workers: 4 dist: loadscope - keyword: "" timeout: 15 - test-group: budgets test-path: >- tests/proxy_unit_tests/test_default_end_user_budget_simple.py tests/proxy_unit_tests/test_unit_test_max_model_budget_limiter.py tests/proxy_unit_tests/test_zero_cost_model_budget_bypass.py - workers: 8 + workers: 4 dist: loadscope - keyword: "" timeout: 15 - # Already under 7m; left as a single shard. - test-group: endpoints-and-responses test-path: >- tests/proxy_unit_tests/test_blog_posts_endpoint.py @@ -253,9 +223,8 @@ jobs: tests/proxy_unit_tests/test_proxy_exception_mapping.py tests/proxy_unit_tests/test_custom_tokenizer_bug.py tests/proxy_unit_tests/test_model_response_typing - workers: 8 + workers: 4 dist: loadscope - keyword: "" timeout: 15 uses: ./.github/workflows/_test-unit-services-base.yml with: @@ -265,7 +234,6 @@ jobs: timeout-minutes: ${{ matrix.timeout }} enable-postgres: true dist: ${{ matrix.dist }} - keyword: ${{ matrix.keyword }} artifact-name: proxy-db-${{ matrix.test-group }} secrets: DATABASE_URL: ${{ secrets.DATABASE_URL }} From 1f6e01802de8a9485c5cc9965259865391ec4caf Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Thu, 23 Apr 2026 15:57:22 -0700 Subject: [PATCH 66/79] Show absolute date in Budget Reset column Relative labels ("today", "in 2 days", "on May 12, 2026") mixed three shapes in one column, breaking scannability. Always render MMM D, YYYY for consistency and easier at-a-glance comparison across members. --- ui/litellm-dashboard/src/utils/budgetUtils.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/ui/litellm-dashboard/src/utils/budgetUtils.ts b/ui/litellm-dashboard/src/utils/budgetUtils.ts index ba13528bee..3d3278db88 100644 --- a/ui/litellm-dashboard/src/utils/budgetUtils.ts +++ b/ui/litellm-dashboard/src/utils/budgetUtils.ts @@ -4,10 +4,5 @@ export function formatBudgetReset(iso: string | null | undefined): string | null if (!iso) return null; const resetDate = dayjs(iso); if (!resetDate.isValid()) return null; - - const days = resetDate.diff(dayjs(), "day"); - if (days < 0) return `on ${resetDate.format("MMM D, YYYY")}`; - if (days === 0) return "today"; - if (days < 7) return `in ${days} day${days === 1 ? "" : "s"}`; - return `on ${resetDate.format("MMM D, YYYY")}`; + return resetDate.format("MMM D, YYYY"); } From b6d0f6b649bcc36074abb32e59f77d7db84a2511 Mon Sep 17 00:00:00 2001 From: milan-berri Date: Fri, 24 Apr 2026 01:58:02 +0300 Subject: [PATCH 67/79] 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 68/79] 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""" From 21e08b0bb52af44046cce546c4e4a4a86810e375 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 23 Apr 2026 16:24:40 -0700 Subject: [PATCH 69/79] [Infra] Run schema-migration shard serially (workers: 0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test_db_schema_migration.py has exactly one test, and that test is mostly waiting on prisma subprocesses (~170s: prisma migrate deploy + prisma migrate diff). No CPU-bound Python work inside the test body, and only one test in the file means xdist's parallelism is unused regardless. Previous run on commit 5df9f397e6: 10.0m wall-clock for the shard, of which 4:56 was silence between step start and pytest banner — the cost of 4 xdist workers each cold-starting (pytest plugin load + litellm import + pytest-cov instrumentation) so that exactly one of them could pick up the single test. Switching to workers: 0 takes the serial pytest branch in the base workflow, which already handles this case correctly (no -n, no --dist). Single-process startup instead of 4. Expected wall-clock: ~6m. --- .github/workflows/test-unit-proxy-db.yml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test-unit-proxy-db.yml b/.github/workflows/test-unit-proxy-db.yml index b9496e39a3..14010d896b 100644 --- a/.github/workflows/test-unit-proxy-db.yml +++ b/.github/workflows/test-unit-proxy-db.yml @@ -164,13 +164,15 @@ jobs: timeout: 15 # ---- db-and-spend: isolate the 170s schema-migration test ---- - # test_db_schema_migration.py has one test that runs ~170s; it - # single-handedly pins one xdist worker and determined the whole - # shard's 12.3m wall-clock. Isolated here so the other 45 tests - # finish faster. + # test_db_schema_migration.py has exactly one test, and that test + # is mostly waiting on `prisma migrate deploy` / `prisma migrate + # diff` subprocesses (~170s). It does no CPU-bound Python work + # inside the test. Running with workers=0 (serial, no xdist) + # skips the 4-worker cold-start cost we'd otherwise pay for a + # single test, saving ~4 minutes of wall-clock. - test-group: schema-migration test-path: "tests/proxy_unit_tests/test_db_schema_migration.py" - workers: 4 + workers: 0 dist: loadscope timeout: 15 - test-group: db-and-spend From 66bf890226e56d14549c43757833b5fd808f28bf Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 23 Apr 2026 16:32:18 -0700 Subject: [PATCH 70/79] [Infra] Stop attaching push-only postgres workflows to a GHA environment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `_test-unit-services-base.yml` reusable workflow attached every job to the `integration-postgres` GHA environment to read three "secrets": DATABASE_URL, POSTGRES_USER, POSTGRES_PASSWORD. These are not secrets — the postgres service container is spawned per-job on localhost and destroyed with the job, so the user/password are bootstrap values for a throwaway container and the URL is always `postgresql://…@localhost:…`. Each environment attachment produces a "temporarily deployed to integration-postgres" deployment record, which the PR timeline renders as a message per matrix shard per push. With 14 proxy-db shards that's ~14 notifications per push, drowning the PR conversation. Changes: * Hardcode POSTGRES_USER/POSTGRES_PASSWORD/POSTGRES_DB and the derived DATABASE_URL in `_test-unit-services-base.yml`. * Delete the `environment: integration-postgres` attachment. * Delete the `secrets:` declarations on the reusable workflow and on the two callers (test-unit-proxy-db.yml, test-unit-security.yml). * The `services:` container still starts a fresh postgres per job; the connection string now matches what the container boots up with. Security review: no regression. The environment wasn't gating anything real — no protection rules configured, no approval gates, and the branch restriction is already enforced by `on: push: branches: [...]` on both caller workflows. Zizmor pedantic-mode findings are identical before and after (same 6 pre-existing findings, zero new ones). The `integration-postgres` environment and its three "secrets" in repo settings are now unreferenced and can be deleted from repo admin. --- .../workflows/_test-unit-services-base.yml | 29 +++++++------------ .github/workflows/test-unit-proxy-db.yml | 4 --- .github/workflows/test-unit-security.yml | 8 ++--- 3 files changed, 14 insertions(+), 27 deletions(-) diff --git a/.github/workflows/_test-unit-services-base.yml b/.github/workflows/_test-unit-services-base.yml index 9de3ac3cf5..8c47b6d766 100644 --- a/.github/workflows/_test-unit-services-base.yml +++ b/.github/workflows/_test-unit-services-base.yml @@ -42,36 +42,29 @@ on: required: false type: string default: "run" - secrets: - DATABASE_URL: - required: false - POSTGRES_USER: - required: false - POSTGRES_PASSWORD: - required: false permissions: contents: read +# The postgres service container below is spawned per-job on localhost and +# destroyed with the job. Nothing outside the runner can reach it. The +# user/password/database here are not secrets — they're bootstrap values +# for a throwaway container — so we hardcode them instead of attaching +# every matrix shard to a GHA environment just to read three "secrets" +# (which also produces a "temporarily deployed to …" notification on the +# PR timeline per shard per push). jobs: run: name: Run tests runs-on: ubuntu-latest timeout-minutes: ${{ inputs.timeout-minutes }} - # Environment is derived from the enable-* flags, not caller-controllable. - # This prevents callers from passing arbitrary environment names to bypass secret scoping. - environment: >- - ${{ - inputs.enable-postgres && 'integration-postgres' || - '' - }} services: postgres: image: postgres@sha256:705a5d5b5836f3fcba0d02c4d281e6a7dd9ed2dd4078640f08a1e1e9896e097d # postgres:14 env: - POSTGRES_USER: ${{ secrets.POSTGRES_USER }} - POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} + POSTGRES_USER: litellm + POSTGRES_PASSWORD: litellm POSTGRES_DB: litellm_test ports: - 5432:5432 @@ -119,7 +112,7 @@ jobs: - name: Run Prisma migrations if: ${{ inputs.enable-postgres }} env: - DATABASE_URL: ${{ secrets.DATABASE_URL }} + DATABASE_URL: "postgresql://litellm:litellm@localhost:5432/litellm_test" run: | uv run --no-sync prisma db push --schema litellm/proxy/schema.prisma --accept-data-loss @@ -130,7 +123,7 @@ jobs: WORKERS: ${{ inputs.workers }} RERUNS: ${{ inputs.reruns }} DIST: ${{ inputs.dist }} - DATABASE_URL: ${{ inputs.enable-postgres && secrets.DATABASE_URL || '' }} + DATABASE_URL: ${{ inputs.enable-postgres && 'postgresql://litellm:litellm@localhost:5432/litellm_test' || '' }} run: | if [ "${WORKERS}" = "0" ]; then uv run --no-sync pytest ${TEST_PATH:?} \ diff --git a/.github/workflows/test-unit-proxy-db.yml b/.github/workflows/test-unit-proxy-db.yml index 14010d896b..49795ad4e8 100644 --- a/.github/workflows/test-unit-proxy-db.yml +++ b/.github/workflows/test-unit-proxy-db.yml @@ -237,7 +237,3 @@ jobs: enable-postgres: true dist: ${{ matrix.dist }} artifact-name: proxy-db-${{ matrix.test-group }} - secrets: - DATABASE_URL: ${{ secrets.DATABASE_URL }} - POSTGRES_USER: ${{ secrets.POSTGRES_USER }} - POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} diff --git a/.github/workflows/test-unit-security.yml b/.github/workflows/test-unit-security.yml index 4defa03b4d..4ee8989702 100644 --- a/.github/workflows/test-unit-security.yml +++ b/.github/workflows/test-unit-security.yml @@ -1,6 +1,8 @@ name: "Unit Tests: Security" -# Uses DATABASE_URL secret — only runs on trusted branches, not PRs. +# Kept push-only (was previously required by DATABASE_URL secret scoping; +# now the postgres credentials are ephemeral localhost values but the +# push-trigger stays to match the proxy-db workflow cadence). on: push: branches: [main, "litellm_**"] @@ -24,7 +26,3 @@ jobs: timeout-minutes: 20 enable-postgres: true artifact-name: security - secrets: - DATABASE_URL: ${{ secrets.DATABASE_URL }} - POSTGRES_USER: ${{ secrets.POSTGRES_USER }} - POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} From 4d2acafa43cdb94d07c01fbb1d61b11a076f9dd1 Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Thu, 23 Apr 2026 16:52:45 -0700 Subject: [PATCH 71/79] Split MCP routes into inference vs management categories MCP server CRUD endpoints (/v1/mcp/server*) were bundled with MCP tool-call / passthrough endpoints under llm_api_routes, so setting DISABLE_LLM_API_ENDPOINTS=true on admin-only nodes also blocked the Admin UI from listing, adding, or attaching MCP servers. Separate mcp_inference_routes (data-plane, gated by DISABLE_LLM_API_ENDPOINTS) from mcp_management_routes (control-plane, gated by DISABLE_ADMIN_ENDPOINTS). Keep mcp_routes as a union for backward compat with allowed_routes=["mcp_routes"] virtual key configs. Upgrade is_management_route to pattern-aware matching so /v1/mcp/server/{path:path} resolves for concrete IDs. --- litellm/proxy/_types.py | 15 ++++- litellm/proxy/auth/route_checks.py | 6 +- .../proxy/auth/test_route_checks.py | 58 +++++++++++++++++++ .../proxy/auth/test_route_checks.py | 32 ++++++++++ 4 files changed, 106 insertions(+), 5 deletions(-) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 84a9c4b793..cd033f6758 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -427,7 +427,8 @@ class LiteLLMRoutes(enum.Enum): "/v1/skills/{skill_id}", ] - mcp_routes = [ + # MCP tool-call / passthrough routes — data-plane. Gated by DISABLE_LLM_API_ENDPOINTS. + mcp_inference_routes = [ "/mcp", "/mcp/", "/mcp/{subpath}", @@ -436,10 +437,18 @@ class LiteLLMRoutes(enum.Enum): "/mcp/tools/call", "/mcp-rest/tools/list", "/mcp-rest/tools/call", + ] + + # MCP server CRUD routes — control-plane. Gated by DISABLE_ADMIN_ENDPOINTS. + mcp_management_routes = [ "/v1/mcp/server", "/v1/mcp/server/{path:path}", ] + # Backwards-compat union — virtual keys may be configured with + # allowed_routes=["mcp_routes"], which should cover both halves. + mcp_routes = mcp_inference_routes + mcp_management_routes + agent_routes = [ "/v1/agents", "/v1/agents/{agent_id}", @@ -477,7 +486,7 @@ class LiteLLMRoutes(enum.Enum): + mapped_pass_through_routes + passthrough_routes_wildcard + apply_guardrail_routes - + mcp_routes + + mcp_inference_routes + litellm_native_routes + agent_routes ) @@ -563,7 +572,7 @@ class LiteLLMRoutes(enum.Enum): "/jwt/key/mapping/delete", "/jwt/key/mapping/list", "/jwt/key/mapping/info", - ] + key_management_routes + ] + key_management_routes + mcp_management_routes spend_tracking_routes = [ # spend diff --git a/litellm/proxy/auth/route_checks.py b/litellm/proxy/auth/route_checks.py index 26bbdef309..6417307f69 100644 --- a/litellm/proxy/auth/route_checks.py +++ b/litellm/proxy/auth/route_checks.py @@ -300,7 +300,7 @@ class RouteChecks: return True if RouteChecks.check_route_access( - route=route, allowed_routes=LiteLLMRoutes.mcp_routes.value + route=route, allowed_routes=LiteLLMRoutes.mcp_inference_routes.value ): return True @@ -358,7 +358,9 @@ class RouteChecks: """ Check if route is a management route """ - return route in LiteLLMRoutes.management_routes.value + return RouteChecks.check_route_access( + route=route, allowed_routes=LiteLLMRoutes.management_routes.value + ) @staticmethod def is_info_route(route: str) -> bool: diff --git a/tests/enterprise/litellm_enterprise/proxy/auth/test_route_checks.py b/tests/enterprise/litellm_enterprise/proxy/auth/test_route_checks.py index 24e2797796..c147c7aae9 100644 --- a/tests/enterprise/litellm_enterprise/proxy/auth/test_route_checks.py +++ b/tests/enterprise/litellm_enterprise/proxy/auth/test_route_checks.py @@ -252,6 +252,64 @@ class TestEnterpriseRouteChecksModelListExemption: ) +@patch("litellm.proxy.proxy_server.premium_user", True) +class TestEnterpriseRouteChecksMcpManagement: + """Regression tests: MCP management routes (/v1/mcp/server*) must remain + reachable when DISABLE_LLM_API_ENDPOINTS is set on admin nodes, but must be + blocked when DISABLE_ADMIN_ENDPOINTS is set. Uses the real is_llm_api_route + / is_management_route classifiers (not mocks).""" + + @pytest.mark.parametrize( + "route", + [ + "/v1/mcp/server", + "/v1/mcp/server/abc-123", + "/v1/mcp/server/abc-123/approve", + ], + ) + def test_mcp_management_allowed_when_llm_api_disabled(self, route): + with patch.dict(os.environ, {"DISABLE_LLM_API_ENDPOINTS": "true"}, clear=False): + os.environ.pop("DISABLE_ADMIN_ENDPOINTS", None) + # Should not raise — MCP management is a management route, not llm_api. + EnterpriseRouteChecks.should_call_route(route) + + @pytest.mark.parametrize( + "route", + [ + "/v1/mcp/server", + "/v1/mcp/server/abc-123", + ], + ) + def test_mcp_management_blocked_when_admin_disabled(self, route): + with patch.dict(os.environ, {"DISABLE_ADMIN_ENDPOINTS": "true"}, clear=False): + os.environ.pop("DISABLE_LLM_API_ENDPOINTS", None) + with pytest.raises(HTTPException) as exc_info: + EnterpriseRouteChecks.should_call_route(route) + + assert exc_info.value.status_code == 403 + assert "Management routes are disabled for this instance." in str( + exc_info.value.detail + ) + + @pytest.mark.parametrize( + "route", + [ + "/mcp/tools/call", + "/mcp-rest/tools/call", + ], + ) + def test_mcp_inference_still_blocked_when_llm_api_disabled(self, route): + with patch.dict(os.environ, {"DISABLE_LLM_API_ENDPOINTS": "true"}, clear=False): + os.environ.pop("DISABLE_ADMIN_ENDPOINTS", None) + with pytest.raises(HTTPException) as exc_info: + EnterpriseRouteChecks.should_call_route(route) + + assert exc_info.value.status_code == 403 + assert "LLM API routes are disabled for this instance." in str( + exc_info.value.detail + ) + + class TestEnterpriseRouteChecksErrorMessages: """Test that error messages correctly identify which feature requires Enterprise license""" diff --git a/tests/test_litellm/proxy/auth/test_route_checks.py b/tests/test_litellm/proxy/auth/test_route_checks.py index bca6b9e78d..bfebc7145d 100644 --- a/tests/test_litellm/proxy/auth/test_route_checks.py +++ b/tests/test_litellm/proxy/auth/test_route_checks.py @@ -152,6 +152,38 @@ def test_virtual_key_mcp_routes_allows_v1_mcp_server_subpaths(route): assert result is True +@pytest.mark.parametrize( + "route", + [ + "/v1/mcp/server", + "/v1/mcp/server/abc-123", + "/v1/mcp/server/abc-123/approve", + ], +) +def test_mcp_management_routes_classified_as_management_not_llm_api(route): + """MCP server CRUD must be management routes, not llm_api routes, so + DISABLE_LLM_API_ENDPOINTS on admin nodes does not block the Admin UI.""" + + assert RouteChecks.is_llm_api_route(route=route) is False + assert RouteChecks.is_management_route(route=route) is True + + +@pytest.mark.parametrize( + "route", + [ + "/mcp/tools/call", + "/mcp-rest/tools/call", + "/mcp/tools/list", + ], +) +def test_mcp_inference_routes_classified_as_llm_api(route): + """MCP tool-call / passthrough routes must remain llm_api routes so they + continue to be blocked by DISABLE_LLM_API_ENDPOINTS on admin nodes.""" + + assert RouteChecks.is_llm_api_route(route=route) is True + assert RouteChecks.is_management_route(route=route) is False + + def test_virtual_key_allowed_routes_with_litellm_routes_member_name_denied(): """Test that virtual key is denied when route is not in the allowed LiteLLMRoutes group""" From 29e30d9ddbfbc276b11e23965f9836ecfb7917d0 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 23 Apr 2026 16:58:17 -0700 Subject: [PATCH 72/79] =?UTF-8?q?bump:=20version=201.83.12=20=E2=86=92=201?= =?UTF-8?q?.83.13?= 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 41334f830f..a47d5194a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "litellm" -version = "1.83.12" +version = "1.83.13" 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.12" +version = "1.83.13" version_files = [ "pyproject.toml:^version", ] From ffaeff54cd8ad8eaf54c797a5af1c456707e6861 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 23 Apr 2026 17:00:20 -0700 Subject: [PATCH 73/79] add uv --- uv.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/uv.lock b/uv.lock index 20f519ca70..d04df0ad4f 100644 --- a/uv.lock +++ b/uv.lock @@ -9,7 +9,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-04-20T01:21:50.985363Z" +exclude-newer = "2026-04-21T00:00:09.504288Z" exclude-newer-span = "P3D" [manifest] @@ -3085,7 +3085,7 @@ wheels = [ [[package]] name = "litellm" -version = "1.83.12" +version = "1.83.13" source = { editable = "." } dependencies = [ { name = "aiohttp" }, From fbaedc36dcdd72fca9ac2379f53fa015d394b000 Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Thu, 23 Apr 2026 17:01:32 -0700 Subject: [PATCH 74/79] revert TeamInfo budget reset display changes Out of scope for the members-tab feature and regressed legacy teams whose budget_reset_at is null (duration was previously shown as a fallback). --- .../src/components/team/TeamInfo.tsx | 20 ++++--------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx index 04b9b53140..4bc7ff3ea8 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx @@ -15,7 +15,6 @@ import { teamUpdateCall, } from "@/components/networking"; import { useGuardrails } from "@/app/(dashboard)/hooks/guardrails/useGuardrails"; -import { formatBudgetReset } from "@/utils/budgetUtils"; import { formatNumberWithCommas } from "@/utils/dataUtils"; import { mapEmptyStringToNull } from "@/utils/keyUpdateUtils"; import { isProxyAdminRole } from "@/utils/roles"; @@ -71,7 +70,6 @@ export interface TeamMembership { rpm_limit: number | null; model_max_budget: Record | null; budget_duration: string | null; - budget_reset_at: string | null; allowed_models?: string[] | null; }; } @@ -122,7 +120,6 @@ export interface TeamData { team_member_budget_table: { max_budget: number; budget_duration: string; - budget_reset_at: string | null; tpm_limit: number | null; rpm_limit: number | null; } | null; @@ -735,21 +732,12 @@ const TeamInfoView: React.FC = ({ of {info.max_budget === null ? "Unlimited" : `$${formatNumberWithCommas(info.max_budget, 4)}`} - {formatBudgetReset(info.budget_reset_at) && ( - Resets {formatBudgetReset(info.budget_reset_at)} - )} + {info.budget_duration && Reset: {info.budget_duration}}
{info.team_member_budget_table && ( - <> - - Team Member Budget: ${formatNumberWithCommas(info.team_member_budget_table.max_budget, 4)} - - {formatBudgetReset(info.team_member_budget_table.budget_reset_at) && ( - - Member budgets reset {formatBudgetReset(info.team_member_budget_table.budget_reset_at)} - - )} - + + Team Member Budget: ${formatNumberWithCommas(info.team_member_budget_table.max_budget, 4)} + )} From 6b6b8c74186569c6c2b40b92a3a9db861c7745f0 Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Thu, 23 Apr 2026 17:07:21 -0700 Subject: [PATCH 75/79] restore budget_reset_at on TeamMembership type Members tab column reads this field; dropping it from the type in the previous revert broke the type check without affecting the reverted render logic. --- ui/litellm-dashboard/src/components/team/TeamInfo.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx index 4bc7ff3ea8..302a3a02f1 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx @@ -70,6 +70,7 @@ export interface TeamMembership { rpm_limit: number | null; model_max_budget: Record | null; budget_duration: string | null; + budget_reset_at: string | null; allowed_models?: string[] | null; }; } From b217ad44d315cfa046569addac5bf729011f0a98 Mon Sep 17 00:00:00 2001 From: shivam Date: Thu, 23 Apr 2026 17:31:37 -0700 Subject: [PATCH 76/79] rerun tests --- litellm/router.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/router.py b/litellm/router.py index 89275fa902..37b898deb4 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -6,7 +6,7 @@ # +-----------------------------------------------+ # # Thank you ! We ❤️ you! - Krrish & Ishaan - +#test import asyncio import copy import enum From 812044a80505f3eb1d8f5d3a5fa8f14efa1276cc Mon Sep 17 00:00:00 2001 From: shivam Date: Thu, 23 Apr 2026 17:34:19 -0700 Subject: [PATCH 77/79] rerun tests --- litellm/router.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/router.py b/litellm/router.py index 37b898deb4..89275fa902 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -6,7 +6,7 @@ # +-----------------------------------------------+ # # Thank you ! We ❤️ you! - Krrish & Ishaan -#test + import asyncio import copy import enum From 35eef7d92ca244cfa9329d8083da36d9c108945f Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Thu, 23 Apr 2026 17:35:35 -0700 Subject: [PATCH 78/79] chore: apply black formatting to _types.py management_routes block --- litellm/proxy/_types.py | 72 ++++++++++++++++++++++------------------- 1 file changed, 38 insertions(+), 34 deletions(-) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index cd033f6758..f55489fde2 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -539,40 +539,44 @@ class LiteLLMRoutes(enum.Enum): KeyManagementRoutes.KEY_ALIASES.value, ] - management_routes = [ - # user - "/user/new", - "/user/update", - "/user/bulk_update", - "/user/delete", - "/user/info", - "/user/list", - "/user/daily/activity", - "/user/daily/activity/aggregated", - # team - "/team/new", - "/team/update", - "/team/delete", - "/team/list", - "/v2/team/list", - "/team/info", - "/team/block", - "/team/unblock", - "/team/available", - "/team/permissions_list", - "/team/permissions_update", - "/team/daily/activity", - # model - "/model/new", - "/model/update", - "/model/delete", - "/model/info", - "/jwt/key/mapping/new", - "/jwt/key/mapping/update", - "/jwt/key/mapping/delete", - "/jwt/key/mapping/list", - "/jwt/key/mapping/info", - ] + key_management_routes + mcp_management_routes + management_routes = ( + [ + # user + "/user/new", + "/user/update", + "/user/bulk_update", + "/user/delete", + "/user/info", + "/user/list", + "/user/daily/activity", + "/user/daily/activity/aggregated", + # team + "/team/new", + "/team/update", + "/team/delete", + "/team/list", + "/v2/team/list", + "/team/info", + "/team/block", + "/team/unblock", + "/team/available", + "/team/permissions_list", + "/team/permissions_update", + "/team/daily/activity", + # model + "/model/new", + "/model/update", + "/model/delete", + "/model/info", + "/jwt/key/mapping/new", + "/jwt/key/mapping/update", + "/jwt/key/mapping/delete", + "/jwt/key/mapping/list", + "/jwt/key/mapping/info", + ] + + key_management_routes + + mcp_management_routes + ) spend_tracking_routes = [ # spend From 863f922be8731dcbf0f5b09a72dccfeb3d3041f7 Mon Sep 17 00:00:00 2001 From: ishaan-berri <155045088+ishaan-berri@users.noreply.github.com> Date: Fri, 24 Apr 2026 08:36:25 -0700 Subject: [PATCH 79/79] fix(team_endpoints): auto-add SSO team members to org on move (proxy admin only) (#26377) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(team_endpoints): auto-add SSO team members to org for proxy admins * test: proxy_admin vs team_admin security boundary for team→org move * screenshots: before/after for team-org SSO fix * fix(team_endpoints): restore staging security features dropped in SSO commit Co-Authored-By: Ishaan Jaff * style: black formatting for team_endpoints --- .github/screenshots/after_org_assigned.png | Bin 0 -> 98136 bytes .github/screenshots/after_org_detail.png | Bin 0 -> 105246 bytes .github/screenshots/before_403_error.png | Bin 0 -> 126198 bytes .github/screenshots/before_no_org.png | Bin 0 -> 97528 bytes .../management_endpoints/team_endpoints.py | 107 ++++++-- .../test_litellm/proxy/test_team_org_move.py | 232 ++++++++++++++++++ 6 files changed, 319 insertions(+), 20 deletions(-) create mode 100644 .github/screenshots/after_org_assigned.png create mode 100644 .github/screenshots/after_org_detail.png create mode 100644 .github/screenshots/before_403_error.png create mode 100644 .github/screenshots/before_no_org.png create mode 100644 tests/test_litellm/proxy/test_team_org_move.py diff --git a/.github/screenshots/after_org_assigned.png b/.github/screenshots/after_org_assigned.png new file mode 100644 index 0000000000000000000000000000000000000000..75c6a8ed5f5d1a9f1b18c66bb1932fc97afc6843 GIT binary patch literal 98136 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 ak&#L}FK+Vny_Q1UlKgEI>35PQ{{IbgQvjy` literal 0 HcmV?d00001 diff --git a/.github/screenshots/after_org_detail.png b/.github/screenshots/after_org_detail.png new file mode 100644 index 0000000000000000000000000000000000000000..2b4d23e254a6711e397eb47a4b8c27b0e4b6d854 GIT binary patch literal 105246 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 z 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 YgvO*ZFwDsJ=IgCIam?Pb8tt3>HvmN!1poj5 literal 0 HcmV?d00001 diff --git a/.github/screenshots/before_403_error.png b/.github/screenshots/before_403_error.png new file mode 100644 index 0000000000000000000000000000000000000000..686c2fe573d70032ae294fdf55dff4ff038e0ff5 GIT binary patch literal 126198 zcmce7^+Q$J7cbp$De3Nzl5Q@Ibaw~{3P^{5bT{xq`N~pMY=?~q(M60#+muP z_ZPh1Mn?|k?7j9{pW0CxY6{pGWEgO8aM((UvRZI($lh>ph@of*;BQiX4-3J;LEw~R zrFFbBb~BMJ2z#k|n3bVu3=E3SUS6yzEBpIuLC?BBE4pwIKHfLW0Ds1T4^9rPU;K4l zseNXu-hqsNaxK2#;pyq=An0{{e@9;{lBouPqS1yzpsi{b66$C?5vi%E7ZfuX|NLbi zF)lJPRMCOwyajI&0VxdS-+wDxzyX1-pof&D2~AV7@cr|MV`S+!L6GAp%{I>!WtZ#` zslU(d+l;tEpk1pX(2qv^@8>1RCy3E#&1tB=3K?Mj`hFgH6}zX;_7{e5Dh zIyNH;v~N22rZswU1O)>dye4?2pVo@HO}4)@T2y`s`_bvM*TZ@@ymaPbB60H9TT#=$ zd_UoeQD0wNHZZ7Vh`aT0ynpgpHJMjB7%BMS$Jf|t)fQSV5!BHxxmRRE${7eSun(k} z@0!{QG_G~XfBB9^jKa5goE0RkPWp?E-{~|lcRP*G^ zQ}*JS`-fM$%(?8Vd(~P^09|>PKuGzlH3QmtJ&IAH$9$-sJO08$If1n?xF)r{CVJoO zuSf=`;!_pe--i<-K;Gw z(fqSPiW0l2AGx+e6V*E-qHuUj(=(EL!$yARV@loQRB}ECB4expnaQkwb3CBCxm2JN%)99 zPw8c)yeF{l`^Z~icQCSFO;jnAObPCXRmR3mqDI8dZbNTp7W$`*h{YR&p0G+#{b~*A z^~E9~pn|DxrOeFr4EM}U^(-uq(S=Z^p!_^6w=hjX8Bo*Fk+qgBPmOe#ESSnRZ~9v) ziCj?zEe>u=h_no^qW-`z9x;6xv54fn8Z;vKTGhBp?Q&z|tQac5>ywF6QZwAOhUQqE z11WhtV}vfK@G<&pvbPWhxs7v3WZk4h+Z*@BUb7#*CzP*`=1iGwZS6lI)z+SQo}oMJ z?G|VA%*?*v(@e5l)_7eF@!56YPojmv;Bn6M(h~Lw|5liA^Yt7$bGtDK-^m!V-VQ*NOiB5qH)4F><{H7b^PQx?x||2C$R>u6S@+$~;DM8&|S zMiBAX+`rxA{H2&8#N#93Ly2W?laAI%HW#cG!!RWvV1!LbTOxoV*xxhE+5WSx^`9z^ zX@?5a==HPkfZvUBNlE7sbiR#t6}9+rjD|EU{65!#8-|%Mmg!C8*DVS){G)mN=~IoC zUSud49tk5(8(P%F3(N*-`|{PmaNO1ILH$8Jr>J)>?s~`VwG}lrHC0u$wNcU0v9W0S zkH#YRO-l3#h^SB;`kovv89{kT(U3z=4;i}{e2F9bU^Y>4&TZ|420SEqZp?^N3^k~ADY*`GEzdNOCP9J(4!O@Yy9nUhGoxX1}vxtDwzAF7DeJl&&n z`rWRs^!?!D_l~sV8g>`N?#3F{6LP`v$@B}xt@E|pQ0bz{ww zbBl4#@%RET%{i+dA@385)&4m*N5)X^x;`-9;Fg<{ zGxquEbe)sAt?j0w!Q91U)Td#~R4JEJ%%^0ZC$BEA+q{nu;Cn;S$Mck|oSd8tsN**7 zZm;o@k^}?<`rk6Txw$>@_wjr4M$^_X=!BG)K_y+|ep%+`XPuKK24_`8jcS^JBb1r> z2+v#m^-Eh@;+BR2d14|w*5VSn&dyx|AtSj#+**W2869tT#*$)(T?eppQ?+MFc(74c z^oQ9Z!A_ma+r=F%9k;3WJ``dlF(d4F44v`XkQX%It(N`*8#z4if2S$(3%e~6ViSQr z-d{XXJ#d_;sYw>Fm^iKOKGle(a9!M>#pt4Z$UC<2iOYvb!n=sM9nT4xtHoGZb$Nh~ zOBVM$6XBKA@3GIlapbl%d^A+NT}P|E^mKG!ox;>*KS{jt*_*35{MppdltB8Vr(;vG zXq-~q$NhM%pM}q=Cl-^KlkX83U2cxg+4lP8ChE`Dfcv|emy^81n_W=^eMrwxJ0^?t z52nkqa&vQi1o-&ecV{Y?)j!D!B_)q%RXtxHdavz=giatLHCA3*IVC5#MrQmI$<8~) zqrN82v}N1or9S6+%E$>vRFl_1G#3^#Gqar_4i3)W1HC9hUCf)FOu}K98>EFm%dl|( zZ}mdTyos+tKIBG=msQZ9fWIqdM5D^3*WmMFg+J`V&md~c5z8WZ@eEscLnXaUX>JRK zRf~t$`0X29R$UUABLev}H~TX6mzK5P#$wtl+^Pud?99R|v?lb!Z8)Xxxua@M0|ZK> z@=;My+x&0If&v5KR!3b%eK`z_MHv(=tgMo0q!ENYzYPxVEVYL~D9662vU8xwNg+!a zmXDj7n9vBOt8DD-?CkAfW7BbP46V6^cw5skCOH1A<#(9j_C4FJd{%J({)z3`?rcb~ zGf5#|ERl`~ga@|FLGBeo4DqYCm z;Tj)RG64AlhpLPf>i2B;lelrQ0nbql-k+R=ZNaDpZR#&zUDluYTL&ER!#hf!i_ui1 z=p_WxEr#2_749ibcKPADF(m9cFSLcA^p+v$2?hMEIoVg$Z^LO`=ZiL2C!cUxbxCa5 zR}c#D6NxIV`{`n&oQlm_$g=46`#v)3(=j*oDaMtQvA?8#(#(q2pe3q}!LShALITwo_3L;TZ-}C4+n*%w!X_Feir7(Ng+V%y0G|SJx zx*u81${$9UGC!JD$_l(v%1CBXTU)ZQwX(_|moH21^A%qLZ}s3Hp(>5bJRB*J zMmm^cwknOqkXQ8sALuA_R8mpog0G6T^VND{NnO0J&-R|Cadz)<8P4&24PY_z{;@n> zdxp-3mozo{(Q9koG?~H8i@=iLB#;5OY&VD+fsej;OrJ<4PVnwH<{K#MUW@WcqQ8h2F#-lnqcYLnj^zkYVOIe>r zKm|H_tBmHm^~S)gENTgGZjt`NeSHZF(y;SFtU`J`C--d)y<^g#l8cKYtD_ctnLn0Q zem}*ZSbT%KEx)rInkdlhthar~WzN}(wKn2?QH8A+z$a`)S8F6AhJii(Y5{Lx3k6Bf zY34a6DL$W*jK;#X>Xb2K_EZZL%~P^7%`=p<{~7QZnLShYWcw|hr*cU1;xdM-AreW2nwr`O zr!l3<Dr>uo?gA(H+wx!$HWzg79b;mT7V7MQfEZ zYm72a?`@EaBDWsXS_$T~!_8kThGP%Df3kUTw0e}BEcW{Nv2pE^e(%(j=jryO1}Wq{ zk5!Lh)$`YRDp8xOb<3SzdvnNw*W0>9D}G<`ad8ud-aqMF2p=f-zxCnb;)=v9PytN> zoG}Oq3Cl_(5)QgZ?F-M-tCHl+GEpIs+1VM^r=CU_FDDCiiz0BT2WA;jY*BG2(uG`B z=oR9n_=2>!!Lov(LD%ZE*!orLbFkR*&d$#-s^oTYBa{AvQ!FmCQA$T?a+sCA1=d|9bW(iih*7{blWg06KXUfP7ymjjEsCO~#^^|I zClBvTQ**A@?5;nf^H*lING1~>N7sVHSkJ)RfT^t`W3`@0Ljl8`latlwn5;|pMEp-; zujx`gwmjc6N}pQ!^*HUEiY+7|AtBP?_utKX$wsmW}s6{Q}^y z<>lp{wT{V{6-x&gg+W5_DR~5O&)D zGvv_&N}=^h|0&jC9ozUagyIJWP#Fk<%QCn2&=zRpSgmZ@-;&^|Lqkm5qysc$uOui^4+NUV46(aX{(cVJ^w`JZ zzGq0NsJRHQUcFi$ObfU^D~UusEY4T_RP3KgCYi!^wiJ}#V69uI^Ki!Ze%3v)?sH!2 z!)rKF!BPtZUzg(s26%|GJn|_aZx9xI+!vSCp5Np7f(5bt^Bo->{+H{-ijPZ->;74R zgL3P!(29XT5@X{l1a#Ug_;j(Pq@=MS9sK~lbRNrPr09#QM=@rZ%fD7I!OTd)l=-ms zG+j67?&+Sx0TF&_4gNije0invJ6hE}_%|}<60X&lwHUZqK6f|2P1Q@7e0r5kPnO{0 zYEo-LObg_TCPNkE<>a&5#y3=`A8R)MwC$Kvu4gKbl79Zp6bl`lry-zH7$6zvs2kIP zcaJ}sbXQxzpogWRA|iCWG&D=IRkonJD8ChQp|iC;g%)UYM_~^+g>=g;FQ**``6KxH z`i5ln@6J|Df7Q1A)#wrFG@26@5fNd2gNBW-pb%00LaT{EDR%q4N}xX|D0YT${W7!V z!(QruSXx0ly6+$O?dPh0ms@s@Fu%Dza~%Kj9YCqi@fta@QD7)Y`dyS;^~SIECS>v2 zL<5E^52kIPsMo&3VykJuIN9GWhTHXWA(YWrNBMV9Cn*|j7P8-E-yYgYzFMC)idKj3 zrbbt;Ng*2=S}gUbxnILpaY?Z&gNx9$hzW5^M@QS@uAbgh)UUTpuG(ka*fI5Pu7Bv& ziMKFEp@{xi#ntG=&8DXGReJU=j+-CK)6_lA)C?;hRaI7w8nGJlc3e7rR@%2C?+grQ z*DtMhS~N4S4u@z|+l>YzW0K`nzh-87?d#hK^*=pcU!s*M)G5WPOG{nTlhLB8TzA^ThyiF`l;D zvybfu&w`QAEs7S~0;q#L_vU7I)=AZ~jkCSd3CN_t9-Qya{#oh9M2ht)osx0AQ>x6g>C3$+(kQLVN|%=12=%K(S}XY!a!VUz=9^l_pAc|F_W)Uy zRt^l>+uRg%UXn8CgTY{}t*w0!9#F0LK2}ChD0EFemd!g5oWO}nShkqkGaS0*|oMjikL;ZjRv2snP|N7_mR+#d{aBL3cGpdO4` zjo3VTY_ztq@lL?eC=|va=6_SKkU*u_Y%}{FuCQ~kj6f$*^wr8VTu&Ynj26?5{1qfK0SP0pcgma@g?$0=j8Q}W6f z_YLqsuSUpr>zuh*6vEOIA{~lu^kiryl--4V(Lc=hmHAuKE zq}B&g@e<68Dy`Ay(g9(P#DUNQY{QfyOlXx_L8U=DvbTl0>PaguvHlhUCmygdL@Eu| zEb#ylvj)-4*5k|BM{a{IAb3mgT$!u(_^NktaBgMQL?;|neuxw8VY`h##q8dcFB%V% zeWADTdX@Nv)tI1D`RmZs%U9!aTvryU^qM-8DzDB2YW42FV^0aings*|B#U#!1CEKK1efU|g%dv2lfiH!3nxDm?Q1^vFdyg~etdrQACS z9m^^s)mME>g%c50+!lDkW!4-RX;CW7!p_FF4d#UF3pzk{tdhw^x_73_KJ$YS?JCSp z`zoj$9|D03lk;P-3%lbWnn&2eymE~~%8ZKF-8ApRi>3bh$=k=Lk@auU_^}&s@{!nM zK-CQ)_qwEqD@j5w&%nxLACw9`R|#Ui?i& zSR7QGT{YG1fAFj*U^+e@fVU*(@{3N_c2 z*Jr{PRC()9;==K^gH{39>El*`!#WIFlxP(52SG8>DGHZ>Or>;~CJiNx4KXqyt`m*6 z3qMa(?Sx?Lp{X@$4CRm3wM805 zg=b{XE59O>J?j#xAS?Hea3MQQvBarh-J!3D7EG09Dbg1b5oz_mH7X6cs)0*w@9pjV z{yolD>#rDq|E>sYL+w5Jk`xyW?b?%Pho;ttsx}k?ttKe-Bx?cXHMOk!Nd`rj^-JVL zL@hO;k$M;@nizUK{+l~%MmQpwEj6XFzQIGU)TZ|#L z(Scr>x$Z|QM@5_o(c3P5C8ED1vQIy(zn^fWjle=U12{j7G;J{xm)9$8MbK^L>xm{c zzexj3c2wh?w_lUu)JR})Jk79wRWDz<4q+?-@E%_;(AnLWKlMFSAtapTc zXG%Z8z_SDq;VguEg>zwp9kJb#%0{NXmF*P^r=sGd>?aL;pgTQ0y|mnfKf6^5m^LGZ z&0}r-DZs+e^kHetawrZhvI@?LKSNHoH$z4i_anb!Qz0&{7_-YIm^HFDb&yq7_!wJ? zZb~4`P+mD{sK8G6cbtU!jd!K8Shx}*5}`u-Y`d0r6ysk(D$FmkpdqhQT4hmZXJ)^6 zcqpyXRg`@@>TzO(byObk?~BPtSw8_)cMT0$*S&n??DNQ> z*}|UY3q}VA$GvBY%n!X5+IKz}(T{|3C-V|JH1S7ddtKuCK(I8^KcYAkEy`C#E%M?< z1nOKaZuav^Yb-aPspA4)No`n8Wnz&Q6|G8@ZE2bfVUFFY9 zW@>+N-@$B8Hfwf9vxo*_o~yD7LXI>4SR#_1RIF@q{B8MAj^6p}D(U7sm|l@Cy-jnh zv;npqAKB}zjSZ%ijB(qp*!QDGoxU!v_$@xs_}$2~YuBBi(C0*a*yYH{$-naV$&f;z zr5YDyD701#C>^0Se>a~tw2M4zv7+vI35nnW9XKqA)QKc%5U65o*WtQ6>_1;H`sgk8 zQGI0R?_+$^N;len*z9SuXRlx*MtoHV@Un-b|lETPk|BCKHOKgB?6j^-l90o^xv z0f!Vu85`)rP0M5{$eG2ra$lxKmq_GjgU5}~T z>OTL^eYK@sur;RO*yo{YxMXg<%i@qX@_z z7Jy}mcqP_dDpnZ(5|=(6ddv3Z5Xd{$>#vBJt_dN^XPmG6F;R z?e)2_vGE}7;s_y)I9PJX2k3ByFy5bO^ME)2JSHb=q=FCtHVT2`@j2PjD=}K0X!O{@ zp%6L*_#E)vfv!+L<}G{dtfw{?-2bzqA0Kbm%xYg5$zZV|qD6Nby``n4&6STEOZW}; z4#=305j#Hc`2ESEG@pU#X)nNtBMC=~l$t*QPT(G@`cYu^=g)}n@F_q5fYM)MKdttb z0UiJ1@^WvcA_TbA=9slV%zgefup^PXxEH1GQ!EXHy8;fsPHtQ0AxU>BRvTe=?C@e{19)rcb;sMXq?W7dBu|>q>tJ2Js=k?p2;bFl8Bq3}dP)0$accI1CYiDZj06jLv>@GBR*ifD~ zG3zMq&o9cCHU3FJTmprD997}$`}9-;K22Ov(i!Oi?}HaYma}SOaIZ~*hW$^Egy8?^x*kjDu^zJXISVc1Em(nK z8HL#R8v8R04-a3Q935#2?)^E7r}A{nR)rkjS`_n=9LOAzzCJikoci|>$9qZ42~mCc z-PpP)NNW;=LN+Rw$8>kB8rg6X;INkT^f`kHM50}!HZwD0I#2&bOXq@t-j#-}@LxdT zF~Xk3GF@jMXixP=;i866dm*=;le2fPRlgEFe<*XwfL4DMaNaS9Id2v)|7`v+FE*%n z{bxf?UY_pu;nRx^G;1AZ9cC-hp{kYhf*AS#_JNEH**&9Y zKRr9WlY@i%6%~p4tt#YY3=C#~)OKp+bGFMGh9X>MOdZ0%c6YT?2DF~XTICfLNlj`b z`3!5Kv?#30gZiX_leX!P<^SpY)BUJiC|`S;y?FZS=RN3z4BU4eJeB&=gVcDee2PQE z6PWq|6k`6OhNe|gOQ^A3vTD-d|lGe z$_fDdSikZ@j%#H8>#3`!8R6f3~#=nmN&r;0z|MNqB9GaS%N%wO3w531#HHeb4u^v$Mf_0F7>bVZn^$)gd6&>p!y0C2bTH(N=)2oC!J{ zV7=Bu>6$$c?l_glOdT@jBX?I^?Pk$VR5w5y)#+FTw)g|A0p;5_5FJZ82dbR zE0c;EKCzSKmG9kc$ zmQcn*L+gA0gt;;`oO9Pvkpu|B)fE-2V8I`l=j+X3N?C!I6L!Gb=DtoM$%H#Xb_}H( zskC`&Z{L};ThJd*DXL*RQSxH^OL>;+0>a5QP;i`I0(%VXx!rtyg|xVOrU=kbik^S} z&@V#sAmlqa9X#ufo-8re_x$yc0wC?<6n0%JFsPurg4s`(f{txe;yM0B73j+HSvo~} zstZhfkaMz^Dbiu|^z@_E05|l@oS>9K@6h@NFm1F#T$l%=mnCxC#`4t{fg}cw!}1pj zF>eSzFu?ef=Ud;1z7j{MUjv2+Z=C_9YaL|$219a(D@axO2aBJo>D#M zK92AxF{jRxWnT-ktJKgzvGxVb-$VlB41mG+jYHw)pzU75ZiI&6?C2<{q1Cz{|GhT= zJwWC1{j>3XG*{sxONO}bBax27pB^!M z%MG4`x=S6L9xPH5B{qD|lB70mwCqHgS59WkAsX>{m}-%DDQ17?6==$Eer?W6gWMq6 zFkfzkhp0`>1160$){xkX5>hw?k=OvCcc6n{#1KAb-W8wO<36((fubpSd;?nUX9%VSyIUQcc)91E5ue7e zA$LJO{MI%&b@YKod$U!jl?X_b1{b`>DF}}dD}y!UIi)0_UbONOaIUZjptI4Su3pk7 z${qgR-Q~d56xruR*uoYzqu)@Y;bqwQH( z%;icj&{Z+ZX>r-mx>D~u>FG~_X{49=ENXtH^nei+KrIW$D7Su<@&v_KHt{O^rw+?mU*#N+ir znO})KkXM|hjYw}`Ko{39H3^af5@^KA$mX`L)*HnSd|v_D6ftIab9K@q{!TLB&Svmk z4d~>-!F;FRe+3&HjjK!cOMwHlj)q)a4d_DUpOWI@4}32WW);I}BZoc;;=Z|FO^}=_ zGdJh-JIoE1t}VZZZAei9Hr5o|zP_aes1mPyF$OTQfV*OA7!uw^hx zwCq6l%Msb>s+Sd}l7(phKWmrDu1_W5SMNUrw8bwf@3nwN4s3oDWaMsVM>{(^Z}0j- z?QgJ5psvCqiO1HGNz{J{GI4NlfMm%wEG8xr<@p=*tIwsa=^cOnT}JbjEGQEn^_SmFNLm^do4LolQ8>@R?r%QvF|n{6&XY zdC_R{M?t6X%!kAS3r@Yu#w+RKL`#zwloe7zF-dj=33Pe?4VQS(cU()=?t)MGZ>1pK*z^xPkA~kFm zhsAWBDToMr=fO%F3&OhAxXM{>2O5`1v37$5b;jj}tHzt@zK5w(F8rB?l`F}LPRBxO z;4>-%_wUbN;Idi8e9t%@9Liifzm2}6&hOtJ)6uN}rFyo~MkZE7o@PU!P#5~DU5DWNY+R+$QD`p32vb2#47s$zoYMlklI4Ge z-hB(O#fb6qOnvw(trswuV|IeRIV5)B^ zxj|*Xk${z?aGAFPVZ5G_Y209;|H^tGMd^N6!si4{|0`zFTioy%Y5aebkrk?$qVji^ z9lyXR0!))>+C?Xb@%PRSrWzia|^-}G8oD_uHlXCwovDcNLe*m9dXv}H89u`mwunw4gLO_n^ zxCCl`X9X_3FgPNhKLRFBQ8-;@-gaKgqnRTMu!pG23KE-klcyswF|l<_I_QD|b|Y}9 ze0ISZ4wWy)nbg(Q3)n!b2VOcJj^!0NjD$qs`wA$&1~rOXCG)Of_ zB_$=@MP(nYIqW~mjzcG4ny;}>qu|IkSARP2a=xBtB8N%!gOtX%Uw(&6L9yrO=ReZ8 zsC`J-a6`ir6Ctc9fJ5g3$^+B{o<3%f9D*7KjG9QlbFbt9iH+g6jF?8zqA{DObtG&d zNAuXpQiFE0m7hgg!nXcnC=h(qK55jeSSoR{9%In?EbiLp^vC;>4zKjvA7ujX6C?vH zV>(#EDg-S%kh;>?_4n%*{lNe+Ol8yE_>txGDlFil)6v1fBHIjL^U@b~sO5*2Y@PLw zL3eDF{j3anIvM}V(2V67N!K;BV$~W62%&N+8}c86S2_(A?Fbpc$rM3LOBN=RAe}I? zCdA0aGc8|@fFTE@l(U1yEDpm;;GxUJ7U&i|+O=F000EE?w)Tkwu;GpM^c-eua1BV} z+>UeAFqsHIEb-KQ?I^ZT4LZu5Cc{u-eHVn}v)V&YI|~(Tq6t|M#F51lHqx7rvq94X zxT{pJr(1Y%d2f4=S-LC)B0;s5{wnTJef>^jcwsqWbe46Cr$UyPQ zvqERT6E><35jd(mD^12`d#+)u;{;MvBAGZ|kAA2FoP;yUDDZ4w9u0e>P3L#$meprT zC8k%vCOGjqqthK&i;uron?OfL$40|ad*AU($i>-tt5d-YfPQJ8+n=O_gvZ-)Ug7R9 z92|1^lLQx9L5TCTxTHjt_dKs}N^ngyPzQH-{W$)F>m|V7%L^LxwHQr^xTco#_mzS_`9vxOT zHjMs>8yo%+XAdRldzAP$B-=c|wPfVuBQ*S7CP+K9h!7Xts2nUUZnd=W{o~dcKwEu8 z)Zjl+ukQiX+m6QHA|TAqBEsO^S0YRUnIO=w2EKC*St>Dql--~D+SVAsm|ncCA6Hjb zcPbjn`>9(Qmy}+c%wFXR*IKM})Ol;`_uf4PhZ$c$(H9S!p`&r)+C4FD`)#ya>*ZxU z5aVw3|5K_kVBOm450a>7yK^Ydg2b9EW*bp)v#K3uvlZLLgiP+wIjPMhowBwrb@pFf z#5^6#eaimc@!0x4KK$i$r8HZ@nicbpuX>lgWV!>UE&jeo(Q&4t0zWdJ?Cjqv;8Kb| zu^QvXyH&AMi2oQRnO8x7#iT?bez%z*{4`N%k&5!rUG04Xaz!Don_S7EI= zbe>ZhSdh@*U!^%x7E&IIMam0!gEOGI;FN{gxvq3YfitpW&Z)n{$1*5HePY%fO{5S@ z;sPofU}(%I24nq9Q6*h8mpwY+LJU-4;mNj~&j;TwuY4wdOg25Gzl0^^c}!6qXo=YP zStW^sm+j?3$K)oLX^|ETOAQ%0Ldr&piCNt|5h`;&{P;-NYFMj8Z93mq^wuvN_bP!U zkw@l(=s6YCuHJJM2dC^;GbU_#Sp3JX;S*)p`rLO>fvG7C_Tf)$T3`KM7#n}o`@Ri3 z!AvW?Mn!5pI-?wEN+wcx@}OP-eeRbA@Mg{j2pIH00NWXag8+?xb8$$^5C~WX5r=_> zA0*)Bs5b}^^+f-O!JOlWk+0eBw5NBvtoKJrKoB;o{jU8>1FWUu$cfsAtGy zpH&(E1^tWI$b?8y+S&XDm7J@-;5tDG;Qch_h(|RfYZb|x>+U#pd7KLISy8k$8qSfq zl^h+JzMne%v%&+$ZMl;2aVUgSnREvjqH=AqGn>30Z{Sn=iYMP!f7nub_1g9M_{$po z(#2qJ%_bvc$@{>ffsyce;i{D<9AYaQd)L3mzbJoXnk08x)GvEu&GeXj8V5I_yrTA% z#z%E-u*!dz8sC1!UbZu7)%HmE*)A5WcnNJsTpGbQ8Dtf_Bz^B*>ZCCFPN@*cpJp{V zr?$K~fzn8{`a3ONtuFStJPG5qSnj2c?$!Vhs(M;dWj(25c*^rnZOiX5Zy`}pM)i&G z^5o>)CgYRX%$T%XudZLsZ?aVUj)%g0YS`$Pwm&eaZl`cYWlkb<{B_|T7VVkv5pT{i zET34S$>Sx@(K&n@_HO*)dQsgF;~mU8sjr}bkzKIW?{s4&l{}&4)1WkJ{twfh&5NYl zM5WcM$=o!_Q_M)%h^QC!KmZJ8N#{cZNfI|^Dj;fbv+1<_WHJjGelH#aUgAV9A*Da@ z$=&+*Ow)I#0rdj^+6|!mq(PY2d3*OW68f&&df&?0S~^{WsLB4A#q=>>xZ8yRJVK6x7rg2+;89yU_e`+{$F4m zXdZA|10WKR+vL1Kp8{|$49^Vs4gexWmSJbAMO?}7y%+U*HJT$AM=+GmldJNc7j11g z6CB=kUi|1zp!>%VvIgr-13uxr++jaejF%M(p7z+V5{WG0g7<%--m>;PIgZf(M6flh zAR8QCCt%3rDkSv4LP8KF9336;6ccHJF6PQBE8p^~gX{#jx!{6;ii~tZaC5nt1)&jf z-+HjJfDpiMKji=t%0SqEbo2w{l149qw74>80RT8?L_}Gk>V}Uh61i-66rqs=AO-d` zg@yL-*0fTANONaL2e^d7jB6R%dGl^VWDTXO+3ynn(>)3{**7Mj?tcZ(xVyR32VoT8 z4hn={(d?P@q3V+wmSuD3rKxLsrT6~DF##pL_C1el@5geydbP3@UQunfgB0)fAT;V z*Y1Jf?k~2r0px9mvE^7j_x<}GpsaShY9MrodkTPf#a#)=Z@&bi2k4K^faK)J#pJ{R z`2PT(0qg*R4!C^ha*X4&W%kT)H%Jyz^-k zt+5;&W?Eb)0t>kc(7~mSn4z7Cuepo3c5|hsO_0C=4FKo?o0Iko64C;$tMGUDxA1Ha ze&BESMzHGiR6eS}I?uwB0LdS+NT#J09vT|B{KKfK0`geT5MQ2vyPN%AO`w=au5e@_ zinO4hBkU?&R^?4NiPNRR6PZUm`J@+^kZx{o72+wPJ|$gp7*(U8XY)#3ODX(neobHl zL=}t{TMfEG_+?fAAFmMHDb@miVzVNJK?kK^kqW%?Z{}&xq@b@+_eXOh)`9wJC&w`h^m@W;aBYms z=Vk<&K>P?U4GO+IcD2c4M*zg{|0adXr~%>g6A=|1>;&Rero286mdl zdcO;pI^UzfuK815S)N8A%-Ef8{-Ngmd;wNqBRS_ z>NWkDRHR8gn%Gge-d|q;{*38 z0Lda0$ipWfpj=v7>ddihCUxFUPPrfnJZ~}kDGqjab~{pF%9^$M2~UIqutzQA4EHOn zRqn3s{!Y{fE*cJBB~yfm^bYKZ9r5vK03IdicEIq}AV5PB$^hE_2ds9+hiDYnNESq6 z1TsOIzjhJBYcoJ&oI-c{HJv>J00K9g$AAapF1&ke?7BG|*9q?7o&Fx*1YFzI0Q=Iq zyb1kMOG7st_YoQI^PzNLodednsz6+Mo6ci(XoddOpo{+^D5z1)Z9@e!4CUrf@i8ee z5GbhHP_?`g>2V(*z;O+bK*>m!%OKxYK8JBFhlHir>f;=6?`2H3 z7gNQ;B3mO|ccsuCL#WFTY3a*DsErWGyUH?m#S|Ry_pzbxF~$kQ*8!un7)L=x9fU`7 z?w$rZK3N55X5pGGply;USL-HXYlgjT#Nc%($zGJoBOEXyVrpVBZ}oe~+n3ydh@}n| z4`?~ZI=~uQPYD89;2sKGE^*lipse<{swUAPUl9a{c~iUoQceWI);vAQ9@?F_DeF^!^wEtu?7(NF&}G$@`6AgR2eb@_4v=6w}ZHDUzdOQ)!l} zN5(ZT!xT+gy^o%`NuE8xm?)WI!aQAyCs&OjeD%u2n#ZeUI+1Mq{&I(P?mwr31GF$xBka#=%u=Yyq9_wrLuUwYZ}2o|9LOw3qsq? zuRuQi7Q7Po#Ho6J4OL)H6-@I#Eg&u~ZX}s4cGaePetv$!-twDqWz?6u5*`C}X_icy z6nMgySRK4S!K{{g_HbEY9!?t7=_BtQnDUjqBWBIrv}Wn3X{(Idd)8>AKo^JJfbn_Q z5w5S$Tv>P=%}5jvUoE$o%1F*gt|-AsF)G!!sZfXoHALYJM<&abZuUx2^otsda^@Ze zr8o`55D4hbEOo20L>+@27Nkar~!!5Krju?r31qjh&aDmeCznkzo)W1 z!}Wn1gS$DCizd{hKY9*S(67KUT=qaNV*yT7=vojNndozl_Ssp|y$)Zzg)r+O(8QLk zwFnA^^9*Qh=je=2-VPznt~`FqLa#AG4!%MR#GwHMfzVIqg~E;IDG|H|&SZ9fobbdz zo>DT(`@U&YSo&?fjkomePFb6?!jc>QfPX;*4kbQyw9%uv;JFov0cq+nb0CzlNlQEi zUi#%t*CZ9eG|`$CX_W!i(0kN$9(YiAPzIJ*i|;A2ReJXjN=fgjtT5d+m5C62nS4BY1Wb8@2cN$e7! zR&cFFUkjeBfx=D!K%2x1`F&&>TPZ(oFd;q(I++1J4eV@0V?rF1<{psdu>#sJ=taGQ z@&Jo9x3q-OLDFL%q)PeRAjbqjR3TGmv}@{oe{cir;mM^Yq;p7Sp%xYNhFFXrimCh_ zuD62O5!H_^YWY;gR=OnHrrKc^zm{`B_YsG|!*#JpGUt*`Rs;pKF0_UZ-%#O_5XziE zqlYGsLe>(dg@u`PO5-cQ90eWkqsbRql*4FHw=&Io(z1$aoRkZ_OTlkr>^sIoILQQG zQH(9EE3;T3W%H_}bHh6s0#X4$%99M;L;U_rO11)A6T1R4U`+%67+!w_?6ET@_!EK+ zOf6Gn>*ZRMmzeik=WC#Y(@7BCR%$rTpTi@fNO=Iaj`S7kz!)_mR08|&E`NJ_@T*C% z8jch~jp0v%&#Wf)a)RYXs@3Q8C7>teXS56Xib)7xg#3ArboUPq+ME_8gr8!wAbHmlyG0I{0iP zixU<;DFtE-7V7}qt8z{mG<&;{L_?4G!~lMs5}kn0Yk!I$p(?LeW=7e1o)vIMs;~Q1 z3zvtfK9qK7g9982h_n}0$dL4Y8nR) z?9e(Za{~!65-WU}F~Ek>BxUL#k)dba2u2~2BeJS?KDQK$G{M?NcI76v4hozNd}^pa z{34Ork8Ivp(kIUW8uxxJ!`w$H1-cIQn|v?_yu(6Bj=-%Wvgz+8Vc%1A^Azz;z@fW^ z1uhXTplDXpWKpoISum|(F0ZD&C@`#IYVBP=d;+1M$N-e{1|Qr8(8clZRObqPdE*BP z6=L3cApk7-kfQ^N)~h4_$Ap$tl`ZYhyP>3XNdW#7Ol#DK@fzU|Ap5he8JYx}v)=Ue z*Z4iFhu}XD)n&^>TiSRjr=F*sz&-)z5y#fKAlb0(wIP9ARJjP_mD>fur(zQX@Y@z;)(M8vIQ&nV5mD#wR^o9p5d zn*X)3Dy3Kc*>Z@y`RS6r1OtO?04uy(61XA(h~)Mo5~xegH~!!{9B_AM4Gc_QgJ$kQ zE6!1p^z*SFc!=dQAjTLye~52mLEgE(y0mm?W;^5?WP(pZ5~)XCT!Y?*seH3U`Di40 zOGIB^|LHrqL|;Fe3gK&NfKmpQ;g2_xE)MB4(DS(uwDS~cQ;v{xuU*YJ;QA zi_^RnD?f zyz6*q`cu9hd|wi+V3B7Nf%S8)>_NdU&8^t?@C-VUMO|CEx605v?nk4E|A(sY0LOZN zGc2?2WJw51Ql}7!XQ*y`xkY3#lQY@2h~3uUM=9JaJAN(3Nd^6u~7xB}!DF?2=IRV+_E{SFi?zDKVXd zrg2<)I`vK#7+#@yxXd=k5G~wRhtkr^jlLO0W1OgcZqdJ82q$3u#Et1 zp!a7OPjt+H(P(9Hu^L2ThKdyoG5XL3qfojdVdMde8ukJJ!v)`0qn2sykfy6OMx?h) z+=1vW^n_7Z*Z;1PI%wt-ASxxb;@h(GS|7U(Ax_K{Fs~gdtE#FxI+paI`h!RoNaVNK zLM5Y)0JB}-$bp?CnmR2St%s7NA4T?DQ$e9kHHIv3lA{aOL|99Mw=mQIR_kUkF9Z_+ zA^9C=iT~*FZha&$)@rk;ympJ)j+L&KD49jGor>tnpzlpa8BgF`c8y3ZaoS%;f* zlFpGC_6 z1D7-&4U<6Z5ytZYAey`H^uqNox|K~wL17ngAM|yJtORTz&*@2E4hF0Vp4|l`O|fQq zpU{e{_CJWTl?kXYV#aIB%gY>hW58ny?GTbc(vz+jB0)!$7LpTTnFc>cjU-xe7zdJ@c36cMOcSD` zd*P`C=6d7z?+7t0d8o`eIXP~+a3Ppi58+*de@S=;Ci!}wO{U*lpo(Ez$(HfC>NM(^#hVF(_33x;3u|7;7Vp0Un9>EO&x!go$4@e5| zmdKBhK^VnuM(|)Nv0&hm9bf~T;R*ThLyywp8A z(|;l5u?z|kr|)&Hm!Os+q`%5i1Y$Orr$tTnpuZoL zy1sltCqIj6*zD_;GP@ske)%97IS&Vk=X z=|VBtrl-zv6m131o(bX38Cj5zB=_aQ`}4HQP2VeHjtoK{Xn&+eO#h<|etx(I?YHM3 zD&r*yN7_szQxF(T{g|#WgQ82f$15*4A761Gf5=8me&h58TW0=WNS0Z|5Y1zphcV3vGEHS^Y1gEnLoXCF1HB0HP$jq~L0 zR%26RW9NE!7i|#>k)E6*Rf)sfyC3#`wL#x8<{#nx4+ZPclJ)tMC5ykyBo?9D!+VBA z*ffprjk=B`ll0NwC+rdvSmO+edAuk*r^(dh6#6`mGSq9 zVzyFKB2zY)Po!zK0BUxB^eD<51Razdy;;)YXX_w4Q4zNh+5G+`>CD7TSu6y?3hS~F8`{33lVlA^{@7)^*#%2`xEEMs(jf9qbxCd zEt++G$B`NOZhh>5~u>ncz$79`b8q;BNO%iXwrl!jD(hP4w#7%{7y+hi7si3N{<_h9lEiuz6!;KE`d-;sy~ zTUs8Sa^_8xQxl#fyze+>l2KY9bf&l}iw08`X+K@EvdJ&h+P8Zp^tzz<%9K}$B5->L z8fFR`nlmUJVQlkDOP_rXE4^XwPw6}uVG=WSujR-Y>I}(=(#H5dMG(sIQiNjZ zQ`eA1OOasixeBRbl!&kP3mTS;z<4!cX=esWRDk9v@-@0GC__)k&*-(QXHOThNKy_z z;YK07d60^sL&xO0Ex5y5bW7slcSD|cfmK<$wB(8Nj9hY_lxdW)(#wbE@y;m~hFD~Q_QPA4OIvTy>r!wwZ9aVENmj^8M@ zTcwp&j~h-xPA-HW`sPo*Viwu6Fy&7abm7f2vo0&t5;gps_i@Y~XHd}M^#n=#S}N+~ zFtQcaKBRv`FYTbpqlFF6qw%P#|G_NHXKKR z1x^!T9J94aq`C`;1GdNjQz zA;Q|`>aeng{mFW=Oy%a*26`ZBPn+(lL1r_a2N#EgrWRY;@974E_({Fcf>4@XZ~bc@ zcJa#b`~KB`s76Cgz_B+EH+LoGS_8S>xDzR#Z}uZkPctC#WVfJ5L6}wG#2b^yYHOx9 zi?I9ReE31IYivv~JG}72>izxH`-rXt!Y_ZIzb9f@3)3_=cDXwyq7x{;RMK)H>D&-- z=BK`Sq-kA%Zy%%8pw7mq&9cf`i6He%6S=SjF^S)c%Ze2R8P?HM1rt{=t$5O z#FUOIswSeE$C^#RvkAzI5=FQ@-V7?Sh}dfY_+g2+7xRjIYYHxdc=-EdGX!oz-cW{! z;(fwSwB?X8ScpnRL647MO9c%%$oQ2@6NAyQ=83PLmqn|9h_mVL2fSY2ozs7x>LfrT zj=Y0UH|l^YQj!VL!!9fXvj$e5_4o6O(@oz@g#8qBU>FEWlUd9yEI?j}hEJ|8Z@vws z@E%c&4FFQW0_ad!WA|@UfC&9D>@ays;ntG_-JjhifxEgUsQmejiFigNER2enFgHp* z-m&vt&5z08r)WeOyyKl1Fn9(GEmC-vn+2*}SXN-jtN;?ad~d;x>0`aTO`o(mUyWc6YXO#U#QC-|dYiuz#< z=l5l+=5Z-f8$(PO1<4wzhYe}|8_gtPD|rd3&**<;in2YvwXF3>YEb7ZT>Ug5i|5=r zOo8+V{!8z03B^8>m7^vZLxY0K$w$m8@Nl--L`XEOl9l)QVnQu+SeRG3zYzbd0Qv4w zEFE|qT3I5h=Mjyvzd@f1sU)ZCa{e;?Gd(uN7#R#j3@nwKQav~60K9P6no|u;3@yy>qJ*fW0S4nuEZ`g(LGG#p{jaN zy*g!816S^w9mR+0ASHq3G=XOPU0z-uS{dS#@Q(xfVd_NGeFLmzU*BFT`#IED#*0Uj z?LfJQAf#${N!FD5&fjv}~lX3&}iHT7x3Olrn z*N*oH7z->hUoiLZy`iDG0y}1?V8~Et<&-(+{tt~c$!|&3V_M4gAw}_gsnbF>H7Wc?jY z;RVy9x^dN08qy!JW}R_VMp6@F9#Aym6m$W^tewgi`Gb~EA8@0{>L{K!hP_7MuYrWQ z@8AUVsgt=aI@4%R@aO+}AEtWa8ifPU%m6_wF5m3eU|fscm3dx4YTN z;cgy+XDEyT^PSZnlQ7}eFitL>x^zdALi5yj4mpcDnRi7=UvkFzxgBv1vy(3#tJ!=R zb~>2A`3$oBjK`|F z2TfvN81LYh+r!C^8~*r0JYY!VV#P$Ni=0!-?s)nlqeENjhRKrCRtan4Og)8H>-6=G zq|9Bnj2B+vA5ml%N)mwUU*)6a&Qsf3hN+Qmn#hnTRW$x`9^ZY=J?7#&HXo4dp*|B* zOsiyZSI%jsDQI*>z6{EtYze8nfn<@!KGuzDy7~!gPweDp9>dzpwtcrqs&CiUMhy_< z#m-YmmOS+JmHNZmsF|wQDg-Ef`5Y^SXfFV4+{CX%xxSLb9A2Tb9v_h7B3J~;&BNmg z@vpum%pHBgKX3&X3_XxChWN6@5ZPVpJLogqTKv05+5A zeDcqwu8cnvT0(sFmFiju)qZhx1IpdjtqO%MwAIXLnZeA$9;!kNDno)VoCMM3{VW$v z#zjvWw0dwIpPBz2K{1@QJHxi3=?X-uC7L^Xwn+pCBCs-Er|l`^Fhjr^;5w$pgvrkw zqgS5fG_)qgM$siIDg_*N7mm1@D!GV^);Jkpj7d{Sb@OW94ojJrcARZK22ug4VZ6>- z9#`^f{LiLn5reQUfti&J04ezqQQ|)#c_XkCBhnEh5e8c%WUP|UCBR?^#E&Ww7H^qR z8Hm_faB6szZ#`4QJb@Netdk33qnjXhf}7?M_picjjvf;-E8cPdD6qAPw{wNWYFB+Z z15-Kz^*LJB@s0sjNFODeEsJ)VfG!4r)}LXv_6*P7+uquOWV(pTO5skpsk6Xu!9wgN z1@eP@M-sZH>O8ibXlQ6qd$q6pv%i28wY;tk|EU^(CyXWa$=!A~Halu0L9 zVv)Il;2`$+H3rxRB&BXa6$hGAJ6FEtLcZ?r#YN(fb*gan63LLF9a>O1U)PjwR0<$T zq=^u#=}78^=>&u|x|w43%bYk8jw5+6L3pG^q^45dy$@p)5RCxC_exW*rYVmmAVg4& zAPTDhsoW=oZK(E)0?y?$baGnVAO2FmKrJb*KVgoKdV`{!4F0TKWqy8pA%Kjj}MfR=3Lxf?z!q8phUW_1$EXvFE z^!4>MG&Foe1vw+ghsKIx8ccjUguAacui1EL59q{oXjO3uo=IhXu&$!H4tjuEml>o# zEqt&6h4-Ha4T4#6j_5|W#@>LgfS>Git$%G5|6*W}u9I|R zN|5yT)BZlJtp5Z0a+5(+z%1>-u_^&44w6H2L;k zR}V;NOGbA4rjAr$s|#puVodAkLVYkrl$$EiYN-5&1-yrKR2;A~@26zbMAJSIpqIkQ zc04=UhQuEcA$NFwNs^Wq;PC`xluZDO8nB^mV~d@=y)3X-0EDO}y9EOb1>QrofVux5 z--v*Q_NA79;~jWkI?a&cn*WNwK1jF<)Wz4dYYgEJU}SysTU`x4%1eccQhAEB`bC|NK#*ur03LKt4T zQjE|U85rykNNFDkEyYaOb|S8V0^ELEX^U@3W8s&Q!jNv+7la}r%wt&r;7BH=MFn_^sA_j&Uz!V`DATkc2*XS_JlyL|T zN`mwc*xbf$E7rqng(UVrn8CI&=|MuF4rE;nYB8ixAt}jdBB1Ln!BeA(K)R5K9%bSV zn(lw$Ai@)4ASDfDgNw1WxTp`$K77X)u^1w?HnP~5m>||F2UN_lweJj6P&H8dAQwWr zZwWpL<~(!iC&UcJ5NZ7!1{9#g*@EB4B5U8MBM27RzrN6r>>R5!C?0~3feQ553wI@W z451^m0@%9)e2_ZO8TPXGTj4tZhz~?#13YPfEsF)g%Ujh513*N<2kaS~H6mc66hFAd zx+5`XxE^ZgApCR(IB$^;H5!aYD@6p7~+QV6;TfN;Q4^9AaNU# z$0B@D)m{UXBIaPTE~>KGXCnr0gb*kkL0~YJ)z#H?=Z!J6u`hsnE|3f(aqsf-a`-`F z9eVQU0ITY02?B-~9w;EBr$J)|@N2(8PaV@|tZU53fe?}Nv9KgJwV~~qD;csR{C)2O z1gbLOxh12b!iuGP^cyye$h#LE?cwhU%8tN$8&$-j!SEYod635emjLO)wWUofZFr!f ztUOPExWZ4ofGccT!*jzc0&dL{9piU96b}YKxq6*hhT*4MY1!#kjE^ntT@vWVa`?78 zNJHHfUl(bfjjoDgj>K9gC%|JjS0C$ zM3&^k?-5lu(DhI(F!oU`pxGus6=vIkN30iPTjV#ZB29Y1_Ya91BG(o(*)-eP#&;sHmtrwRCT;vksm;4Ji9dj*%>o zl2Qvcs0~K-d6;N_XMPMRbvOr2xs(U~gwF&QtLOe48 zPTROy0ro~%_7@6r<@~SyHkur)LVQ{g5WMk|FI`dBcYmD{&0|9mT#);Qfc(#6X(+zG zXY}B~gZX6y>-`Fr`n46uV45uMTUjCJxC0Ca+=Cv*aa`=AipfAl#Q92Kd`53RPb9wf z=r@nRDnQ#Jo8b3OMaBkF4w|N5;v?C*7Rp4d&JIury^wHX69yx7O6Lt9XxW54A_4m% zaqjrmpB5+D*VUsynG$(~_7f?#sPZ25?yZF_6mYeU_sCtn?_oub=rqol0@ClPOGJ@B zi$iI64|FY3ui{@w1ssXEJP}D~YLZdK^TPT4cptYT`KrfqFH(g_U~dO&FEAz|#~Jy| zBkQnCvXBS%^@TvyWXXldPL?<`r{*M1H+T2YARAX#;if-89s&y~=JQK$0$x9CLb?=? zC7<6IJbxPPN&#G2fYQ;?(Zrjte8IEkh=U8kR^`@%6LPpO%UvOaSgwK`;V`hY--_xo zIhk6RzJ>H*SAf2!my+B%xKltC%{ZZIBw`VF_!&E5&;jq+&*-(;tXF6s9~N2c9d7d>J1B>n}B{6Y@wax`0Zsa2WLvM!|r50z1Xv&gZFV4O!}_Znuey z6OqU$j8ec|NCRvDK*q?zx^W0}8i&CQQhl_Ss9vqQdlauzOS(?;^V_|JyFlWtNOXS) znKbF{`pcHYHGusBaBC<4N_o#DDY{usx=~#2f(Ft~3g$RSkD$26B#ti*^U^#}MWe(} zlGQF>WF3diCX-u2LOied;m!_R>`yj-T$jxvfw$1L!4sO9B#3t9alk)v_kglONLWhYHXF29UFhEzmA2h3vh5u9S&=F0Ur`9ez!Bh5?c)SrU7#M2 zx1Hvv7gaWrq3S*zhS&fuogDM0h=*J{U=%fe8WG8bEx-X(MQ)Yg_zD&{Gr1_3v1g60 zx=hytjZi;r)7Q1p1~V}Hq1EUoB{?Bfk^&DMs#n-GZM3IiLTZ-yc~;P{DSLjv;>-mH zQq(x=885kgz_(Wqxn_?N?=^l|$;ZWWtF1{F#g410HYu@Hm-6jan)rjMm6)c5VZFEZrdJ9+!d%ux$xwc5ds})cNvk!;m zk@IN(>)^piqc}G)pE_Q0jfqlATpQzgH7%nGlXCruH?+mWXWCpj(%v@)RtY5^e*0E@ z=|*diXB>HJ!|Vpbso60f;j_NYE^LC!K(fWn87a(Xce~g6V$&{Uf(Z`J#$wp%$15pS z>{&z!XyskL_3!VH?3*;YsrN0akw7lAT5(0-a8?tK4yCZE<3jW6*9PZhyaMLc>j?_f zinOGo9@oF;qx(yi-03ps<@&`;OFQl}(knQ15lRqx1Xi9;C$<59%)gCW(uvzN%3?Np zI~|^GUF=AzTn*^VunCWP`pkRw?c)zcpJcdiz7QlQW*T`Hoa^!ze6m8>Y6lt)UEZz! zllvr)o)u*vPQ$g=*F5r8vdW>2NS)IcEK~I~0h5SozKGw1 zhPe0mvNn3H6m-1BBKu)@c_!Yd*^dpwcH%aS8Wkq*Rsk7~sHP5|K2fT0c#M>+zg&dT z&>CxCZ=8ZaOwxy)C4PPvsQ~9x^TMf#0`jG=40KOO^#&BIucee7Vn-{%N9k}l@Fr>||7jhd9L zniIB=sf+BGE>cMtxN~{gRkf5G$bZLmeY6~%z@OpZx6JPgbe#wppCg~o-zW9LOYgkS zD?;?6JVZ0$^zvem3!*cO`u(lCJ&t*-4g1ZW(9EgAUI3X&;Hsc8Q7Y=H97jyfj7)+2 zP18ruRs)AyRr1AHc4-aNzn1FJF36v2l$6tm*m)^hTc^jDe}C_Cvi9~)u}k3LAB6`M z6iX*dQ{;SI{p0kWcEe&G#%)pi&Xx_2-UTHegoci4FFlxhuk!>)1$$rJO#kuAEb-vJ zlbcg}yAj4;Dt*50tOO}$^BX#2q@wP-PDq_h1)sgltlOOaN7#p!SYeT&zvU}zT!m6p zO}7cBiwqpVW(0Y2$+QYz^ciD+8(751rvrZNHr2! zRjlg{#HA$TT}t z`Tz^%L*Q9Od5B)+)vocsR+_)QHdQAq;O6z!33H9E^X`1GR))v|jJwPsnS_liDugmBq&~YBjPY%ht$^lXYy<>Ul)%%>0uVEghgn5|bPiSDgj_Ma?I+f;cv-MXU`G3v~Kl*Up%DS*z;{WSIQ<_kNo{luYU5lH!Nl| zShe^8j}`S6sN@QanyhZ+$j{Y`UK%Ywe#dpbXY0iC3F>a>XHlnVSH`LLB>mc11u@-n~)=;Or-2C23mcE)u#jz)hCLBo=fnK z<4w_DU50X^YYun~Y_No$U#0YUoJAudBm1JjyQrd(_~qznXg00^LyyAmSwMzB+m3l# zauI~zaYF0Re8G=TumoVh%BL69Ef79a02Qmwk@S2u87Lsw-dHc8{o&ikhd1=AI7qwA zhcM`bvVod_l^mo4B_Lsuij7j#4S6WAbP3Ggv{Zv4`;%CcZP>5#%-r&e8Or~|0#sH| zo`C8H3~ajbdKSo&Q~cY)Zp3IW`{Ln;-U=PuR#m$8R$o`=HDZX(m>Yr2~5ngcU|-fMRWlHpn$~d1ci_&W>6(aSgTi8vpG#x|L#wk~`&h_a72Z zvMhhu-TM|BpjLo*dNXrtb0j1rq2*QHa{G;Y92Hw$-21v#V;vY-gP$reb-nJ3{ z`TqLTCo`X59xJmpF*8XaeuKuNPR_EtKn|^CjK>9DeFY>$Sy>m^{F6nt!y7V(1@h%) z5`3mF6-PTUm#4+QJ(+H(W|pZk`gHU5>d$cPs^gnfcDk20U-&#)pP*ieXE>;=X?8vg z4M!vlm{2OydiiYhl!FadcH1w?AYI6+%=EZ;d|OPm1P;#NjFGqcEl1Il?mi;xfm~rKK0`-X8c9szhVN6%`g?woyXPzL zkm7DV)p?xD+bZlmQJ-J=T=Tc-gngZ0L>}n)MB9Zrlb zlix*?`mHvl!o8&xV6R=joxL}EHb0w_lV44;xuG0G>lnJ@2#2iijn@X{@Gt9C99P9>*7v7{*GBB8umg?COWid8 z8;%|H*}9hZZmgn+PuexV1$EYHly`}<+oDX*u7>Lbe)m7DNH^%Toos!j!Dvk;@ z`YD2kOe8!8DIQZbwtuJGjjIe(o`FvL$Ktz1t6$*e1^Wmc79qXC+kkI3XurI=z9H(l zoFvo zyk_0Yid6oz7vi?Ei@9yS$E#0?IS6VNhh|O@@mYz-Z{}(Qd3dB?_tm8pcmq4-?)n&Umzh@n**>a3fA3GvIg_)qH(Jp({`tiM*nHxv7Dsx8V?a&grFZ1V zO-O1B=aeSaJM0`SJe1ilHEJ_j!g`H&J2uSz&QRH9Cl-MyI%RGNr0-BA5uDKXNo~yJu%NHnA}ps})CkMGB-J zpWlw3Me_IGandcmZL~(=ac79@Y2lPn#e=@_UsrvO8g$0{6)c*9{AYT~{~?e`cJ_B; zBCs0Luj67W_14|mcy6@%>A8f@#pe$L6VZ+v(@wuP!>8W6@D(@^o;eUwUU~3v-*phS zmAK5Uk3DTZzK`V~-;2Lx&-u=f>-eAa)G|png0fbPIwE*9DLeyN0u)^obM92kq}(}| z4rHJpiMX_^qj`>LhjZ3tvtLSCq;q0nX;kLNS%a3jq|87ikm%E1IisB}03jh*`^@4V zx}6d6zpg%bA?l1ebWbyvDzz`2+wVmTY1fI|w%?<(Ws@@%>ZeSiTXlNmgdPU;LLaqC zXc+!eWic%Je8r8%hx`o0bUh|nI34(LB`L9YzSX)rm&_H0UrayUn zEaun8AidYF%*MuU)$E%(I767$*716Np2U@TZtS&eo(7H8(Zn$Gi6)sn`(1p|3I)F3 z^J^@1%MzNjBqSOA?63L?z}cB7O!x<+na5EvWft6va44oqhvMx-6?&36GYDt>EA-n} zga}h4AL8oDYZL|g1iCnLT?ZXa!p=^2LMYtCL_`N$TaXId2Zl8eA=@FXci=QNE>tJn zw)ceM3p2tcSo#0Gi_k@iVl?uV7<)rRn>oWZRd_ao+pH3Z}S-j z92Knw;DMTNok#P-;g9*L|3mOec0Q2{Pnps2<9mdJ2hs7W(5OC#M6QQ$xCHc?Mi?pp zF+*DUAs_Kykc-8q08ju-v0@$IBZ1#LA*8t20hlzsWB?}xfTsa5MKlBu%c6~l zxg9^hGCG9;1PUsyTXY#<3alx5xV^eWuTe&1K#~0f>3zc@pc$#OQs8izY21gU%s%X6 z0&^LrMT?)&KUZE^9;f`a4A{hhl8E+Qg{ugz9rM};nMY`RD!pH|W#b)16e})D6lxZ_ zsKN<|iQ!WlI_RWFNb^kQwQYbJ0R;m%23M#k~~eb1xQDbk{UL7h^hc&7<_a1)-E(z zu$_GFc}lNfZ;x|a8k2s7Zm@jEk%ZSLkAw=Pj45~$1pYV>3P~vH!4alKjd}J78w-ny zM|U1%u(h?dfQKP>Tov}oFPIZmtOBMi*1qDSSF&>ow zjv5PKeAVxB`b`QV%}c$ zwy%XN_NI67tPsgO5?4@rV%`yO;+n>aM zt^gY+4TmB}+~a_s^V<2ayTF}P=15XXZJ(2F6+i}~(P&~R>v1j{9W9ERFtoY=-d4(_ zd?lm;^#kexFe?3;U>w=qrvzjzayGwcfk5b=lJ`&?UL{~-fNY!u2{t_IBsieTBKD_G zQi5Xj-c<1OgV|d zl(w}Dm|yZ7>uE^ajey8demtCxO4&TwwGwl~8lQ!QeY6n-i|k~0vD56s=IFS`L^0{4 zgvc}he#!Tv;*(G>qY<7s#rYH+(QLI?m!m9@YuUleYZm~49auyR(Tbo}*v^D)4K6KQ zmR9%`;9-~bUQg+p2607%@JHsLBh+KiX29M=4S$daJEjc5v*YLMD;sb`(#Tb#)34TJS~qdQSZGLW+~pT_C6w4n6~_sE1RR?d7mAa1@C&03(Cg7>pXIq6MH& z?&W}^(|x5OJ%&}_4BlXu?={z3OV}ls3yB=k6zLQ(!B_TA9zz)vZeVff@i0?}fOeAN z)hl2qeuI|33~~Yz78ap-l2Q!$h0zFp3RFX~D;k{Odbn|QWOP&yPM09HGT^pOTq4$Q zp6dDR>y2d;b)>nq5i(55u0H?k0f0YHaZe$?G2glW9dPVjxq;v?T+WpwQ^B4Ag9cnS z_-|9t#_|;PJcjga5-ZN5?_p%Peb(Ul@ksgry6e-!%>)%y<%UGHC2}-`QtFmH!nF*n zJj(eYizpW4rmz1Y4wt810m~-_xdz{%mJRR{!!+CJpsPzV?fQ@K831REL9mS2>~;-l z++_ez?>3L{XE(w{a=vg~bsl{C-ta6!R5cX7@WD1XrxLV*%17{(q#}HDsR-X9sRS|e zQbS(!R8+;6?b<=WST7F*Kncl9fIeu_odgxLR}tDugjm%g^d&BOoa@SeuaI&$!v_|G z;2J)m{@wQCZ5jkkz1u<3g9-GEVa6D5XHdC_ZSlccF;f=YR zogN9e7zAk%apZZzG&pI{myzC8;5Okk=C<8B$9&fYH6>ASW$ZQd)Skrr)_#_bx}4Vj zEz6fjTCMz`bg^pN9!+rf&{|t3`^BwPOfGEI0@g#|Wp0LHTwdHbY26P#`E8Hp-}J^r zZ0!W=`ySM|s$Nlu) znmN;gM*S2gv~U6)oG_J7J{Hw|LsOH7FLzMxb&#LJCCXFvUuk=n!K<_KuDSBTF?O%z z^rI)qX=s`^a?$sljUIV>MC=H z&kO5)0e!ZX1~S{J+$3t@xyf(e8k^K)B0D?Tn_B|M4eDoNvMvG#LWh@QzTz7Sq-uD) z@8oK7+&yTC-1%`USLpqb<813bF%j;~qIaKV z>b*0#ym&F@4?xHgR(Q(-W242q*_X7aNjJy~<|{p5WFx@Ml>w43xb%GzRR^DbQo!k- z#QAT&bJe9qKY5~j8<<0iO?3Hz4Dt-;JUA1cohcG)`<4O8CRKRK=kOy#MXQh=Jy2Gh z_aYOrKyCrUQFwGB41v(8@RS!RDjkH2hD{5YcUAg2Z_-atyhT;W4Hh&3NQF_xUj6P| z4x@FBT)^^`jo}Z`qdWt=SDcOC*=?_}N{cLAnl>LyWMhjAbakfz^4Pbdbn9)SQ)>e#mQnzi?rA(CLuc7l(qMXDJ(%c^F zlSS^UH-4%<2pRQXAACh;6a+_L^yp+32je)|j*VD~#Z?F8itzhS#5w8v_oinsUNT(s zK1h0gR2Mp9z~Li#=kA*mgRrSmix!vd0;qv6++h_Z;o|->6hfj zu-XNxQall^a#!a{_LZ+{8VR}KWP97n=ryIJOZOH9?@x@8viWsoSK)qHEpvcKzXMOi zy;U-^_)Rl~nxU_fCk9U=9&zOoLMOH&g+hRkfTV(X7(+Y0UqTWDkQg3kNA3fjAqgVT zC828o%6<-xCYY9<^a2P^Oe2CKd;mVZHjs;f8VULvlJN1&YzNDWjS3Dq3|lwSg=V0W z1qKGb0Lzr;XcZ$^TpFCe3eixn)}VV5jxk3o_4+8E;Suun3`hi^flvOPn0#y)3rA3I zmT2~Utqy9rM>|<})pmYyhZubCNU>#p^cm|m42ky zvoxhogWwP9X3gy~vv)S9b5jQt!lv!tzvDexf?PonRhxJ=Pu{v0+GoS9?1#*N4bRL|b@qLU5-OooW{i+d!q1N+y!db{f>{b~+ zhciMMU2GqQ4?RPyZ>W6TXTFMhUR2cVKTMuGBU3l#lbUz8rSU!G+l7|%*S8=1EWL@v ztG74U*G6j|y9!vD&RJkcax#0(OAOos@tB6#k!w$Hv!@EAMn|t9ASZgLeyHcX!SDQ$ zjEvcsiNd#b`Rp%6+aj&gc6YVw{Vzu|ckfJ_Cv$k8Us|V${}58~;nAxJfhM=ZnIE50 zqc0L~4y-CnL@PtjKr)>x>B~uoZr4Lw+OWSiY76-TaFoi?b(r=Hq5qP8gMkV* z07%?;ytg(Qjp#=C*&>9925WB=3C&AYCd$y$s#zwd7N1L-Vc^3CM zZ(h}flU=p}Wl(O_iDytq=5Q~#LHoI1Me4xkw>$=%vpbYMC7CK@lCdW9h&kd1?RFKb znPi#SOWj$B%xr4p>WI{3n8)jBtEzI#$oq^Jd7a=Us+JNcgk_gCbg~2uN;M(5l$AAq z%T}AR%Y56I7sMFMEv_YL0|w$@y{E|YZW5)m?z^=mSB z&8=(G$>3b_CuWEpwzQZkvE?~KM;k2@B_2yLp3LPnP;Eu0CFq%q&TQ=z6Z2ZkJ|JKU| zft;4yDM{^YJ~e{#$4e6}0I`j~Ir&ml?!Whoii(NI0-bYR;y*C*`lX71_%Q^pe-;noCwQv4c*Jkt-+{>y$eJ-DbC!gE%Eeec70Tw(x#x639 zKrf+$)J!E_9!rTfhTY2v;rSJ3SL3#$>)|nzO=f1^tmsSKRS2~QzUMu~HZ!G(d{lx{ zD9+^j8q59f%{FHbAAEax3qmxlpK7S7U0+NRqsc##U2lh;mN&u!G%DeeuLa8WoF+P>g| z@|Ga&{-!;*{Tz{C)pjtEa3g1$(J!r81NoRxv5-d-f9UVW>x^)h{e3wZ&!|~KQl`C} z(Q&vV3M1VV@@<&jP+Ds%SO^=9!B)&<*B4r_zhWJ)0W; zRmXo}h=+%>IF7~Tsnh86%SA#}2!)i6?p}qI@#@K}_x1AoE1R>yUYi$%Z`n_K+{#6r zCtMNQR;It(?Sfsty`8g0<@VC#vk&a} z$AUGzW`x@L3Dpq(V>Dq&hasydw?r}fYCg*TfPmn2uST^2NyCb*@L$1S4OHd)y_`}7 zGN(S79ZtAjIP~@33~a2!CyKXRI$0@`3_m?5U_~dxqD$h}pz^f9*jX9MF&l-nJUL84 zG-Z{33J@1HJ(fT4+u4hJ=gAh(Gnspu#O=v%E;1bRg?e0ef${#A0{Ow9%HmVi z@Yvba;m69pIaB8;p{+j83tkFAyZUP^H@?GY^wnjWQGSiO0g~by6hB^lsFIx$YR28eaanjpFQinZLHZdvOuU=4TT| zfW>PjSy|_IUTe>PIh4X{aZ>Yo`6WuSqK1eNQOn!==MxOy#OH#yq(pc$d6IW6ERuR> zs0QD99_H_+R~-KOrnJGFeE#!vLRd^}U14Jf=Zk9|9OLn43|&Xq$PpR$e67~HHH%HX z+P;qm9g1|b?{UxjUq04*K>@)z@;-5{6z?X4E3PexUj|hqO11Gdx@+tf2o5-ktPr(`=W8&-*&LEviwg zWU*nnT)^u zj#%`K)ttQ{IUT8En|pJuyNaqZ$(OcuKYFQJlq49M-6&M#bwBoXZC8v>Js*{NE25VA zW^%fGwhnIN0P$d3zp+ogkbjoZcY2yV4gQ z+_UW#qu_TY;fE6?^u1p$V9)C_O=MKp$Dy-#n~n*&X2sesw4O?Q%RP})UU~JoCS7>0 zeKP*)5qg-|%S#`2EKS9aI@!XO9Kml6TGbaZo}8a;pW`lllh)g-rcnOIh(115Z(pZ` zD3p>hGc&vrBQ~N~&c}pR{%6ji-HtkD?KOGj`2G1F)++lStzJ&DC;RKsThQ5<%=YIoM#V z4xa`G`@ZdjfZDl75Q5x^xudG8o+Itk02M@pXhCSU(Nn?v%ZJ!i#3mgc=I=h3PT9Hi z*oqSqN93>*9EP8d&d&aUY-GA}F6SbG3)E1vN58|LK4&*uUCWAHmhoENQ_&IT=GLgM ze)Zg1=&gF;X*LLYxJ_F6wjI+)Y7;(@ZLaP%dO?!6mm)_ygw*r12W5HYc=TCs6n2();|Fj&!b^J1Vig zE?=z>%wx2S@OdR{UdvRTIAxY2?@#+lm3g*tcYgV2>?>`msG5{sUYqA*yu{2hglcfQ z#^G`7SMU3(u2?P=vF)j4bwL6a0z6XPzrGFdQ4X+Sg~k6UEUq_*mM*W;E$O>pP$vpUpfl=r3jHq7q*K(Mzk%&x=60G&Bv=7ZBGUWY5$7mz+Uuf#}nNyog8FSzIE$e<_h2t=o$mI~f)=fE( znsffOKS+g}C5!t`?Jvno3u)Qai?KEX-IOtnp;1<)ueUxCO0L$}RqC;8Q3#^2Vukwu zgAD~R8vpb{GGB&{5~fEH*<{x```$1!Q@#qNpW}>q*ZPo(l$4f%p`eX{MF^qIaN)W1 ziG4m4B5SHd+}}WUv+Ft9bW$VTWDJCvhbncrwUvb*=w;n5(_TM0mh=?+mb<#OOYEg- zy}6|ne!NZi>wQ!$IJ$1e!oNINByS3ZaolqzPwHKvJu|kf>#0hZOjR7AZZ2hq1QT^9j}{?Bd4e zy#0DEU`;!c0?SmR?MI?*cU5)ub+2nr)yXL- zU#YXH_pp-{s|MVyM&mOrzum*0ne}oP*b^iik3hr@_k#}`pi8b^ndQO)dFhkwdUr9) z0J;B>fB`q#|2zj7I`EFF0{{2Gz-464BekF@hkO{=i2zOB2TK+}nD-&_f-V?>Bf7!k zunbl>z!{6?pC1j0f59~z~v{36xFI^D_H{*2BOcsot-DFQvmXV8Cb8I zdyQ>cRcUq<)NbH-nE_R)BvNm6KL8NKf4E1N?j=+zE8PE^dGoxgwpQM2Eiu{P&EfdY z?ryjeQL+r|CQ{~2kIKifH(KA^b!Bd0Hh=@GNZO0(guAe|VJ!^=&oQ`9RHtp=CnLhc zfio9IMn(MwO2IMnwL}kaq@ssIB5n>6r|0uob1_x^EJp?o*e+wCvMlP3ttqBI7p#& z8yzz7fk1ru9pp1+bsE8kKV^t60im;egxL*&F|UBRD#0y9Eu()1W>=tKA+F= z;P%b!0I7o1tQj63Vc|+JZRJ~>(CcaM4lgibZHwTKAA#Kg7!*Mf5jSvr!zQQNwU7;w z2Msq8uzqJb|z85ir@nctOyW&HrKQJHV-K|NqTnXYaj2*(0lPB(zXzDA`I#Mv3f|ElQ*! zq)3aFRQ4W~O)8rZ8L4dl*Xenl-~ZcnJ>ybw&gb0sd%ni&mey$q#25}idLJSLY}t(Y z$B$Pyw7#~^lar6M0@Ur2rOS^~gPu7gAmz;?@CUpqxtG34@8aO89X_*Ul`NNZpeU!j zjxN%tt)&^q7PPXHeL63w1!Mh|f%5F2|H#uTeJ-V_NIhfcNTU2ntrr~XS|TDMSSyVO zebTHoebBH>GPwQ2x-{$o;@sh(>edzCyDF_u>g(&VZ+UU{GgsSnVo|*jYua7)Sy{)A z8DAY{Ci)G+PVX*>_9>`2N|9^w-)cDpVAAqqmtfuzR|Sn&W&sVe4=Ry@olmXHunK`? zfb+{H#H(Zr0s2dsm>t?AKz-sl{T{I?f z4qFFkwY)HMZT(M(iK1r~FE;Efqc-~oUs@&*M#5xhhj~-W>lgm~F|xsoRnaKoSs)P- zD&RVp_Vi(GZ8T9(!n%Py1ZFj0T@g}L_Wc2Uf~3S$*4b9YC~oFV@BFTGO`$#0%poDv z%+yIKuN;r|fNz@6d}g=Zo1qaXL2jTHwAK zg{L6Bm-=@E5NO}gKMU;&eDSH5`hqB*BN-H->vztT&^ZV?rSoS*4n?_LpBh^eGG z<2*ZXFCVDgc-=;Ekv-Hs8Z%GK_UH|ebRjAe&q2XP@>)r55at2$15b8mk+kxMzJ~y` z_76W*n7VmPbm!&r3;|WAM6=7=%IhD20tOsbntZM-Wb!X8t1s}I?>Ro&g{ z6`Z93eb}CpU0q$^nS@9Q8_KNKYEU0ld2~c02DCQ@?cdmEfroN1_6J7d=+exgmsC)Z_p< zVzF2vx>JPg=Pzh+5%+d7h&n)HgPDSS%l&him46a^I;ei<#)Wv3F$iCxIZq{!eMW-> z?1&O6C}-t?DZ)Ow3zXNJVaPRQ%CQ5~Tyv6oZk~j|FxKh%>TpUsgD;F9zQ!qi^oc-a zoBXbGO(p<3FfVs4_L1nP81tmAF+L_gqQz)`UgXDvNBPCsqoc4dlrXUKe{R!<-M28C zVaE2>w?@QM`ofJ$vPxdxyX4?T_M!xm$v)dfbb&lADys8p`$y|37+mw5=yy4QgpAAc z)*f!*Handqy{ARS=E=?tz(+_lruwWGouR4c+ogeAQ(J$9`2 zBPfue zu)Do@>HkxBr6Khs`NU)*G6@^Pzd}#ahwjI{=(j0dUS7^`u<>mqE|S#>xt=@bfedhI zX6kyI0-6KdJ~N5G?oi_ zam~Wz9wisPA30Y1zcNpR?mf3y)H>9MJ*018OE+(1-@*i42Xq?GPZ!c zXio5za|p%V&OT?$EMDb8udigp8azlJUGbXY0;faIapDc(3s3vC%qiV`^Hi4UnmOe@ zR=(;sb)7#&{6z9*XFMe^GMG`kFO1XX- zi4(O&P1bEg(U5n__O*<8ls+SwVvjAJ6Rxu7liN}sjM_ImK2H1nBt6sQA?E~aZL>LE z=nFwXa=0+nwx0K(quGJC)K|FXQ@7d;aAh80+0&sW_{+~Uz7ZUiQFzNjhy!VwiXR{D6e z7L_M6;|lUT$+#*Er9C1;0AEa%n~=y~#{Y}J^&J9b%@X^lTds64m0ZzUy4A#0GCdUz31qmRI{ z`8WHNa^jN`PxfHaU0YzMC>V!z$y1Y9vkTU$+O zeS*%jaqxfj|7fCW6S&ic1(N-PdWrFHWQC^aE%oGA*gNvD1gCYBGS-x$c*88T&3p`% z0U`Bn`<5e*fJXWaKi8q-c}K_3_N>-t1#PS=Gd)r3qHPXYKHZc;q8H7xT#amaAWg{G ze~at7(Y4X4=+oK=+skcLMSHf(6FvbAW3(|ECV3e}3o%MLDm*G;W>axOtfWXJ5NXwk zWI=s|exfjAqt?82PNNGQUak(X^-+@Jkw+dMAG~$^pJzRPCZvd0Xt+Oc1#X7+KH_Uo z>$KU^`Q+qh5nk8F{XrXouY^@2-~m@YW-dj1{pJlwjw9eJD?SFzL{|0`G%^!;;M_6x z{ridmxna71sRuB2_&r9*{s}&iPnG*0k)@v7|A{O`a(gaT{nc2i^+vkrWL?}MO-oop z&e(4i(FVemH>6P~jz`jl%HP!R5M%Vw9NroFCQEbnHnN^C&Zp-l-Gh~$81rj;>M^Us z1o8scDP%hfkgVl|wQZi|lh9Z%UAP$+k8#F%gbMyQ3pg7mAVg6-h?2%B!P2pvEkS5U z%r}zl4UlUHaiKpzgCSIN$Q-0HrG3LnPvH;+ZWQlB#;d3`s!GG;qO}-C=fd%7r8;aE@;^EOC}0IE_urywx3+;6l<4rhfwvt7Wl|0u6V$d$ZL6ta9g zY_9+g(xxHVd@7I3({HavifvnyPd&M!<7i9(o^(Xo<4vZDw0U=w3!uB^um8#{_BDKT zQ01E3(fOLai$NfJPg|;dvIQObEi6KpP(Inr4jP4NjYmfw_hLh`a3YtG z!_KcyIb~QZAFQKDL}Gkeb}(26aV5kz?s!E^l8dyeYmYlhL)?b$JLDnqBmwuyV0To2 zB&pBjd-Q-<#{F4B{V~Y$ETQ)T==0;{a70>r&%k)|u6L$~E`wuV1uFLgw=KO}9a@(s zn3TH}s0o^Gb*S%J&$Q6C$ZM_<9|vm&uFe^4rvtqxA~+(KLj4Kak=ImLxGLwz?wj5T z#4!T4h($c^p*dt5zbKgN1(0DQOnsOlhRq?iI+`64x8^6ahe}l4zAR#Y?6iW)3K|WW z++a#_kM zecwG(?5=@jA_lUfN$9p%#>{&5zor?LGp`borUM>2VOXk5~@ z)qh%j8@1@CDdpwm=w>tu-+wwl5=vTm-XRXP7n`vQQ$4KScegW$vy?8pzWG{+gM&(& z{oE8`?q8uKoh>34+5OLrO->O42FBqItg)J^^!m}DVE8?e6(hHgg%@X_J~%J?h|Gyz zasTLtRKhDlKGPr3k+45qEe^lz81iGRuJ*=^?z%;lA6el{%|=T#IUhq9s3@-rz7^^@ zXTaNhNN;1HBl0~_@9nIs-mG%Qiipux9_!E#$=+g9W&qw<@<04>-7g|rQ|YPtgO8@V zYtY-?8$dnXJ}^Cfc5PGEqqQ^lBIA-|?7Q-HYN7~hHGI(d+xb8vFMDTm{YoMH=MOt2 z^7rKrG*Of;oMbmXaFjL+U{=dtH;o+jP8@F_jLM-zUJPR&BPJl zTU&oaF5~{5>-h-Hud6NXSuKz@#~r{VSCk(`h&%YRO0?sUrRVGbvHk_{bcP=XScbCy zg>DTiPK3A9xQ&1?g)|_8f6xhdczU+ov9&GeuP_pm+QVb-M!xHqS;`gJZD&qH$R!uR zW2IY}lhTy0+eMX-|8^ecT~PXTO>snaD*MV&-f*kK_J?2Wp5lxf7TqSO8~9~J61@kF z$Dt957{a?hyJJJB=r@~|`2MW549ICA8y%(0(fg8c>?HL`!c`-veODiClDeryZeNO- z>p{oFLl*nwIe94&%7f=NR+qdq=Y`)K#X*I7-`0-_{OUgtctVz#Le8S7E@(ir#{8LI z`9@?Uje*SKPk^$-*>(MX?~B<9eSuG<0oty9i%Y+Zynju9=5`C}_zeKBbP+ITlQ#TK zzQ7%#+jQjVcciB02&P{|dM(>n=l@mx2>=|D{KDcXqG0A_5nP4aEZpwb1g>i~r0~9NToXyfw|CfD(&vDMetMg8hZAIH@zz4uX&#@4EC{%>!9pRb z_~>6b$dNcQ7_jRQ-bDgh!2e31&v>xtq4@*09GbbuW>84!R9Utf@R;g)eCs?-ez`)P z8|B;%GHr(1ghe~)BaeDg&4g*03amO$%9R?H-kg>j%!{lO;!;zMMOwVQ-PaRkGPF|9 zFopR;yvCEJ0~RusftR1nqWnZl3GIL_$g%pY+BPZT?vVPVp`p>FzXw4EOqifZ^h0%P z@+f0vWu^N?7ipdjs@^tmep!M&HW3*(*&9bTg?VV>=a~P|2~j4^)OauJT(!+I)R*Liju4fi6AP54cI|o5>R``QeRTNX2tBJ=LMuPZWVo2{ z(21#|)c*|a>ZP(WLl?P1-0XaZa6oyDP)Zz5oXNn!07h}I|9dEU%|a=mzLU3Of=!ri z1Qs+a&^Z<~S;P?TzHyR%?aJaJ6%Eb9OQv`V5z1@imqM;xyJpTCY}rl@y)2>BdZq*6 z&$dmq=bAD(>tOZHe9OjtP*DFcUe=AGTh`}?1? zW*&iESO>tPh9vLW$uImMTLMd*7mb|zB;gX|3i(Gkz2ena_~Q1qN&V=5 z>~X720#M36zP=3whId>0U1;b-`3%=Yx887hRKR$FGy4sF{b~3BKrRCa=CVw?UI$YNpJT6*)>#>fbDaUu~XmGO5I1XLNP&eBq` zu!e?)Lbh@XM+n)a3Y1pxOd=^sMsX;UhLitjfek?_j3glj{Sv6Igl&&-^*D@jE_08F z1!_A;8yzQ^!yj(Eqg!T~)+PbOp6t^|ty9FHpUk`vgWx?*@^TQ{6{F{(_iA!qJf?4Y z>@wzgX2R6P#yJhOd^VObkOo4u_n3x2HSQwQ%WT-Y-~~V0&nNxm@*2MEPN0<-t8;qU3KKS3h;O)Kc;HPA;W&GS; zZG?Ngw&pv!!p?xVh7otXSKD@Kq?|tOy5U{o2jN$LWJwRK{!jG(3L`%3%vWNAeRLg- zYX+QyJSevJlV$%qLhEL z7C#cBE8HwYk%p<3_fLt zsNCFdAh+N+eTF#)VlQTW_ZS>YR4nK#afxqfdTstdprRTm0K^ok-?WU3h&1m+M&3rc zz>DJVK^qE2VLOfabqJ90`1VJimlAVhT;15&9opD@^QNJM%jM?YBaU?ogkIo|V?|YdEM7Bv;qL!9mmw(5{{X zuO>pdoy5pQMgZ{&DS>23+v%UFjQc)-_@Bs#xoC)k=PtrwYj<;TgbIL*#RFl?-5f*u0B8BwB-bmhF zKNUD;N4um$G9LZ=$m@*;fusc36y5MDblNmX9%T@%e)sMjiv|SNu!z~kd;yl@ftg0!}C*z6%a5!(NCRaHCVcf)CmDgtt-#lQTV zoTS5pZ7+-SLoFx zeB`6HV96v1>t{n$}R1|>i8d_TVVK;Px zbR-Dj3Mb!$A5xqdy-n@E=a8`XIw%93`&iC7I^NrigFb`79!~wAz5@i$Vk0$P`ABo} zlay>#xNK&JeY-wQiX_o4g}O*#*2ESC9zD%OA9R}!y?sj8Yw zult8B78EFI?$aB>090I5U1I-D^Z29f^+K^7jP*N@05 z$ZOkaZqD&We+MA{B*CP=(Q@Q2$?;5Qa5YjsSWi!{92-MOACkq};2kK|jJlI)YKcHm zm{f88BGm|zm!V|W=zlvsoi&UBtPTZxB)LqAsAJi!PJLU>Pxl^{-n(nS3$h&Oxz_

_*f)U6^cp~E9$i{~7AN2%sDiD(- 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Ϥ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

PDk@9I`~Tx4ZutVBGxfItwR3 z`Q+VqZmLJ36r480_v>PnHcMSA<@vpD@1Gj|NjGyray@j{)$OSk^U%eRP1d73jw0h< zY)BSMu-QY--rBKf)18?n9ymDps?FgP)f=-D?Q!DvGu{Q0ce|46@T5@uflY^#L3IA8 zN|AkA7Apakcx_*)P(}V)`-M~OP%Qe$79cmG@uio^`K+9`$XC|KJXBgna7U)%qon^X zcWYu-xvSg(HtWQPqPA^xzvahlgklWKqNJo`Q;N9s<3xC-5wD$9C#(7F z+uL(e+O^I$->3To&QHIQnC%w2OA{S_xm)Law*O4~jF2EYLkEC|3 zw>i1&jYe<~j5OQCXKe`w%WVXRE+krgT>pUIM)AhvmZJrg8zI+vPF<(wkNXn0%h$M> z_4ZxYa+%Ll>XXXzVvi;~INe(TwK36Jm>VN-QER!hygesbVccyr(?6#7KvqgA>&1D; zZsiL5n&_r)m*w(q;v6!oXI#bl(8$QI2q8M9;>999ljHqW`Ut7}QTZAbcuLEI`)g7! zEij0(0hdS&g`R#ZkaZU8-05 zqDWLt9^QRI#*^z&2Mb4S=PH$Q4(8wX!H~&NXl%GIlw~r&V7JjC^wUR@(dH)wsPnE< z+fS}0Afg2{-#U#rZ51ooU0VOBI-;QlKbMgaVjV9LibkEzoNnvZaTRebQV)pwbFy5PZeqMqQ&WRc9X3N?8L@-zLs}tXvf#C0Hjjce zbtmQNvJ~;N@Tq)vx3+g_DJMPzlO^)H z3GqBz3j66E&xM!h$DSAK)@67YMS3##3`^eBbTl?_gs05niLsHu7bjK^E;IIN&l_ax zCm5s_!-buH9=5&_x`q1Gjr)l-6U|ravnY3DEPQ-f9w)Q@%+8SxVhBc_Iy^kY`cChH zJyD$v?b3foY&8(|D{OyzsNgYLwjm**;+5uo6x_@INIlytKq{dzHMJ>^|EuK^5r3h& z{i+Ca(dNP%v4A_mKWk&ziPc4T62m>>6Sq6s4~DCsn7(4-t+5*+55A>K#F05x{j3JY zN((j)e=VAJjf<&`zAhB7Wh3ux_~6TExSqb8ICGuq*;9K$2k}*>x4g#a!C}7FesCE>}k@qfk!6dnGI~tDaAkwIDCk|L2 zQ0FTCkUk8t7oT4B%s#*e@?V|(Optj6s_+}47|g@Mwgc7$0f$I{jbdpf#g|yG^?y6K z0fP*U)R5@-ypA7fCeGCp80cT@;_UGl7vCJLemMU6^=73OeiZ((G)r=OV{`LN_pd4P z3gs-#96F4>y51s8wKn&@M;y5u512v)V=X5)fAy_YfT&gCH%l>xu%^8;{dMK`j2BR1 zRKSSSO=;h~_;tg6%Y#-FRLmO@uABXu9@XKGlWIDD6g)NXjBM=D)0+{ap^IR$ncVb0 z^Oc#Ts@D5`FXBFRh97<3Rt^nvp^@vm!wJkx%@X71rl;v^F7MwrU*x z710WKXA$-g*T=;0{Waaj!vbbzV-vBaw~+OW&Xv*xaR9l&FWWM z#%MeXRwTP}vTM0AiQhg7pxxPJWIhiM&Q{*ybD3&QHZYNU&t+5#fV1j?{X#oF8#LA}m)8$I?NO{A=^nHqbIm0BzM zzX=o1u)R?s4x|jF9x~6;ENM;8b740f;&XNsFnxzD!vFbXWSgsw5ieGO3<-1b3#s<_ zTao;qt4rZGL#@n>wzI;hKP=<-7bxg`m+iG*rVtS&ptf3!jLY$oz{|(GnolV<=RxT~c0|U-rY_iUC;h~HVk zIXz=PiTg0u_LqU|j5vhDX1lV@cwQ>|An0x?>H3k7OZ6Q*(v{c&u@T_M$oK~ah|h?-$aY6{5KpQw4lCXwowXW9Tkgx!ssNMo2Xc&^ zGF59>b+M6K2eZ#?6L@8+4m`UwcU?Bh>l4>USrknHwP}6L<`chNbvemeX_ms%FWH&p z=7w0qTmD)tFia+(SM_eHl-02q+nx+zc5oPY+<=_=s7BcQ)akq!HVRpa#;%mnN31RtXe zm+aK)(Ie{wmc6bz+aDFrpWQ^4I#(GecuAmYJzZc!)$9?Nn`cnsBXPHszyL_{XSJ?@ zjtMyf7QdtzI*mKqXw_1?K9s35Z#MrR-;%~j3@Nnzd7KfzsBYZRv^Iqux^CY3Mw(DY zQgXe!tj2Y(eqZ1OGiGxvxGsw6WwyQ*xhZ)Wcb@F;&di0m9FAkmGuw^tdjB-PT5{~; z<1DO33vF6xEP|u29(>f*)61K1x=(bpZthg_>Gl_|?{mWA)t!_hleGC1&Z=K@u-lYP zC7wywSg+y7uF%G?x=H$Y?q3pu1-Q*;1=$iTMymqClKH*$IRe!k$e^6#b~@&z5f}av ze>3mQ;+$ik!t@ukinr$Yi|y$lP zZ+Y>7;`|4AChWwyHooNy051=EexIJVSS@lp_=YFcWr$JZ*Kv;NyhpxS!=PJ!Ol zxWL)&LKx=6O(I%vNvK#v>;7Mc5PW{hYvNFA*v{j#zU$ z2_^99VK;6@&daQ>By;JZsHlbOdyaKy{t63lq2dO9I<<)Nt@Pz5=u{eo$ex{NsMlBK=#xg#bCvTwF{GKQnLPZ{(bQi{3ih8(fnP+Ck!O>}DgL0ud39G^Ydn zsSJ8J41R1bmAH#2OhAn7XKt8%4Q5b{M0QZ8d&H{0R;1USZ}X?>=;#=!JHb~*?o*** zr;rSZm%JtIOP{%X`t#8Pk29)-+OcY#39Wd$1s{f;Bd@7M4<3^yS=43>a?1m)YjhMV zo9@l*cRN?F#wW?qucGz>=lohE4r#Ocmb*4-3vOJ<_`ZMmFbl=spRv=)7F9t(AzVRz z>ecmMkYH_u>OYHM&S{Mt=SVUhnz*$5B!E#m!#yueRTc2lH)z;~Kn3x$$OcNyV1z zfp|$fLA+45WMphuwu`(^*Y1XqxsJJsUW$M_nsUeZdga*@+Q?^80Uv^_tgP5Ms#q>( z38F*GR+f(L4g{yMPuk+^+0y-(~<@-DJlVD z`$^cP0LR=@PAIo`iJ^0o!*+zNm3wky;)+bkr`Vw=Z~|v6 z9e~mVj-{J_z|sTc>V{d@KpST(o)8;bM^8@y%$ngyo&9<&3?v~W0JL^VL@}7Hc{wfv zrx=(~JAh}^Qtxtn#8xjtpXFp7+G( zk6qd3#<)XyOAwW7r4zMKq?@hMR9fs?a(ey-7kaoPaql0`?fwJ zIWabPKbyuI3&yGtVa1=fxf6+sLP@7yz6z(*+!%b8osB=m#ihInd|I5Cn23hhb?s@K zGJ7g@sc9$+D1I>lV9oIZ!m1Gh7}~M`CKeauA8?ze}QovOOWJ z7a%Hqba74L-p3G|&hQ7A(n_df*zxM`N3qhmvp-bz-$K~s4dV0Fp zEc95hnbUBI@Aiv5#UvEcGJW5I#6!JemRxsiOp?zM$6hGT#MG?LsUB4-OT@f$5&8m2i z2I-~t37x&UwRJJ?im3VZ)efLDT(Of`9)kM=Sk}A8{R*G~LFfC6hz5FHNz(XU^F+7P zGJGf{Ti?E8J^1-Pp{X z%?%9FT-a;TNSX158-#5-yvvZ9LYv0}eN~qnYTx<*1UUm7P0LN01uAmH)}GH^GY%Vi zj>afQaN~|Xy7eBmNYYZ!{27=po}Hb6-A~qpruTZ)V*ky;CoG?D-s4DF=RD5wVNivb zNzX<}ISs^)76K5a`mjx3?ti|F$~R29d}Z!@<#kR=Oko0|kng)bPf{%YG_dGO(nt4= z+#$eXL^D0y6-Bz!dQPNOWYN;4^7u_S3%P@McY~~LLE4>hRNO=}oPqaVHDyK$avx0| zws(joQYT0Fa$2k3eCqE^-RNugvoVm8Q!U~lh5NbSS1!LrQG@Im2k*2>BQ@pc&fV0aJ_Z;Ac zemX-4lo<2CnK4nHzZ*L`?48UN$kl>hOoAU)vR;ULw~20<5tC)sKvXM1)49A75tpyrr-CX+UuB?@i37 zuWt@pg@uLfT0ErlB(p%z?F10C{#?l*&2dfvkBB}lSa*qi;5{3U;N!n)@>-FG+o%cI zsz0x;p=~c6p~l)<oLmT${$@@lnk1PpAv{s$W!?vG`K<)S_ZAZ*)fX7wT&6&+S@W zVX30!kAo=>(fqMZIzJRy_{YTm^moy%XL+1m$@0bHtBRxI-Kh+OY3C3XKEcx-X_8uM zb}cN?${WMUMn@@IBzN!n&ohz%|0p2!8v3i!oKT`km8#WS;KopmogNh#i6`#^xl@;} zH0`nF_UtpZ;K%o9np-~IX3_-1(={C~6rw5G@4aa8RLSTcxgdoTGt5GX@SKtrwfa62 z?j;kHz({`$C|%q6_{DW7^o%8!!PxbeJc261`)j7?rA-i}!F6apBs28ObtblG)>|FV zupE`lqky=fBVB}8XeQ^2=5~>E)5-@9=*oL;U=yPSXA;`K(~#^R5%t((HPvrNHt|xz zz{G6sY^}19zN~kg@%V|}Ye}*vbW?0_6eTV60;oq;=Y@}3W6oF7?9XAFJ14D2xA@I< zoAHW{l8xkKPKRWjo;+5Xb-=-&Z_PrtIHIE_&H3Py7V*wH;YY738&eKi%1u>-#{2$? zW!73EbHdY$Dnbzfm+BMfnoa^YzEoD%0i8`aoJuiQ1v^lOWP~90MLDFIyXl~wLQhm- zt;XgFXkU7ZEZPUWU^m;fdmY45pK;at`oe1UCQATNZj5E)v0}%ux(g2u4m?f|A146! z=*74x4#ELj;VwuLu1tIriuU&Q&d68*x|H|47vdju@sl}*d%KsR{AbAuMwetWNudEV zUQ?s-rRJZoO|1ync(TwFt$9EJQhM6$(_Ez|%(gf8T1fguvPZ@QN&*ugj zE*AZS_Pd!2rzS`_2q(lnqs+RjoO8Oj+es9jFy?7+PnkYgwA;4D zswCx?G-C7d5qH9~BuIao?hd!{BgtKKO%g6Z7x!(@3$h!OERw?rUrQ%cN4V5(@5H3O zCB|<0t??_NDu&ti*XP2KzjSGirP|S&*alimz0mlWCLiL*+STF2m{E>7=~mvg{y?wX z{Hep>lnxh)!0we@VS&k#eNIl^x^GhJXMNF{hHNb=LUk{CJeI`p`faayIF?!#9(`kG z(Q>U&KzozBJMhYcTCJ1a`n4nzrXO*b()1!JwcFu@`IeG89G8|AM6}x7QAR>m6L&*) z9sD)pGj9E{a*kpa)%M22TQikW{C z4JZ@7*-X@JqzjFp%f5pnQ!Xj-cT_Jb%5+ctJyI&#UB=k1{XZ1;Cec zHvi=4Qf_n+U|A~H8D{CAXzs=3QfI`hxzyD?BGy6`!6}%@C+{Ct7pxZhFzcr4pBEap z@1S2ih&@fRUEh>L_|GeRJ2vVP?xL_Y=lWtd{|<*IesJSo909hL5@Ibe*AF=Oi{pVT zN(?yiTIu25gZFjJ9-2BTZ`T3TVv%)SL8YnuMa`*lL1l35Q9$%+ zYxl3HhX`{Nhict*(bq2#3D|Rj#m(kIL__0+WS@=l0QvswS55)~g1>K$ z3zSJ_kgF*IJ=OrT*-Av|16mIWfBv5EhAD9#_o^MjFZiBF$s_xsey7+4Qqg5j{9VL07{e#m>8VyZN273CdOs~zBK?YyNK#$W_lzn*mdWXA;j_oj=zng}|PA`bm}32ss`r=Ozxc;hox5 z>GD}WrJN$g48`3Mg_-9>J=?+-Z^6Lkqk zgH%^7rnFMU>V7ggNjU3N6URKO?tRZhbNG-m00L^QWYnnTw;uiTyY|#-)o{X%Yc0dC zC9}PW*LAl5Ccgm$Rtw{v;U1b;OeRO!(njWYNgPp(;u$rHY54DTA3l5Glq>AhTHU3X zo+pchlw&(TnwH12A7?KlbWdl8px}p~gv4Pn9o9XDkDN3#JkI9DL2koL)62G^qZbh0lT?+br_bEp*DFySQw+Z+w~Ni>CD1 z^(}c87uM>VYbRZbb|UGXnoPwV0q&O1JH3b}KK=F!MRTtz`*=!8O__YbA|)isg@bX8 zQLSn}zJjRCNR8>?!=r_XsEbPHovwF$-u_8xwr5SGFc217x7}@wDK>zHOL_Lm^TfG3Wd=s*?~N=cRCu+B9Ki-Ax+>lWnZ`&uK0v*_pW-Y6BOENGHRZZ~gPJ}(1> z_CrKdLqnb<*?~gC=22KRR6Cv2E-ntZ>*{WPjX3j#T%tRV!H+IpnV{ic=+{$} zGx9Vl_S%Qq9LwZ?76UA%q>$N_MX|o2S-pHC*gx>m%XHC1vP(-3G?{==eGoq$Fdr`~ zDRIhqb4G)1veJ1ou*x_1{Zq7~&2vw2sq?|NYm=c9H5LUjT8P^w%|~koN(Trx5XcZg z>HVy?n>=MU;i92qWWTA28jz^9%_nQ!#(qKm>ZPXW!!_*v$_#NFro)-#=Zax!d0MsB zy+f!k8QMFD|KxPnN~5Z}Z?-%$rA4bA$$rz|{N(Z5^4^WMfiVEp{OYcC3U>}%9uS@~ z)IJ}qb6BACu6fLBvOeuxmh&=4UW>=ZG|*cr(sG~m5_nevT>!(U4#5vq$*{weYzaIXsi{Z0trBx?hfz{ONpc^e0b=mVQ zT^QECJ@{R7f*E~U#81i6`jbnxz~SUy$`()5zF93qu2gu_NOoy?1f{`Ncd?Tsys@cCFRZF8 z$Dn+vYm;zr!&7E$e7t63@oopB2s`%HedEIv4;b4Eaex#|hpJvla!UyjzS1BghH+$nid~7;Thk|e49@&HYg_wJ6 z1sXa|BNnar2{JLhMIpiPm#r-nl-S;l5=T9wrP|Qlm#N@8;>eMntw@NA;MlN|fu`D9 zkZ>-Kw(ggEkb5rvq{PiI|eyv59xqYp(q_;?6Rt>NRZlA_z(;AV_zobc2MVf)Yw2-Q8V+l(do}B_$`@GlwtkVYbV)*8hn+uKW5mTTC3Lwky1R+1)X;e7e7W z)fx=sTyX5abjPKSD3A_t+Qy!-HIT;byN{BNKBjc!O>|sCH%hX_RWh7NOk#NXNfLho zm5Q{+dbRS5naa`LuFK5IDB<_T;N`o>t!;=IBt$n?UyPmhDJHP1bzqAG?k!N$Jx^|X z;XAn?_FxvSXQGjOc3nBE^}DJ7kUk%^niz(r)OQ2|1fJ5oIX1M5=4G3swi?3k`LEe# zzI}5}-6IGJm#)W_&xJ)a*R?VAaFH^QMeslebBu5-|ES!DjdeQ1=-@wh(8}+R1J()> zm|$2VekanVHDsmSWwN$s@-S`qO_P#`n&#nkeh+CB#t5k@q-VGxjM5%i?@bbZ1&`oG2UGfp0&FYY2 zYJ-TTqp`8tIMN52deYF5Ug5gfwiBXY#|n5rCXD%ohLIreKIV9;Y8V2SgMNRCsD*=r z%b7hXcOKhP_) zA)rtjJPD@#ZB37h$QKcqFT#Pule{MA3YLA5c-QJyTn`R3;HFZMzYE2 zEDbsjxIS)oov!R~hWknc-`%ZsSQeQur)QSWyE*c6>X>PTKwG z|9BLMhiYTx^t!4beHKKb9B1RCT0?q zuMcRE2)QoUUF!a{Ry`Uv>U3gLV{=|kG09gF&4+4F8P7G|>+a?`_><@qa#Xr0 zU8a3#sU3=)3w>Gh)aBd+!?TtBxti<5)D5r9tgPF|pHVb9j#vj9rB)9TPmPZQ-aNYd zn<22Y{e9alw>w%d0^h%}iQt5I#_sX^Ncs@^QPv*o#;hgmCu{Bm;!Q zk5t@GrWX4PZ`KhiLaW;U<^z7wmdm+Ql1cn2@@2MHQXi)BY+j)su1y;ZDvMI4!5|y* zj7ccb5rbb1;AMKuQ=bHr-awYc7rs;4NPVQ=LH$zs*fs-3cD!FdUF;$^%3f2(B~xh@ zP!YUaW;LYyTU0^-DGyn6y6>EFm`u#`H4fH*mqr;11}FFZE1>5$geEF3BrM@@JJKxD zt@5>*QW8(83kYBP>R2uhf(Q7h{m9zV=wR+Qb5hGy&Km#1aYJy|9J7-fD>W4hl*S#{ zzICGxbW+6R1tIr2Idi{MAYBV?ihQ*3ilt~J>6M^e-}U1iU(%j+P-deK`TH>m$D7GI z#pZL5=QQX%;d;6_-Y9Mo=J+7QcE4G*8&9-nAPFnp&ZdJ3Wm``pO>whUvUE6oHgI!xKQFwARa>}H@7nDt4NGn6=C<-wC<_Cn=Uh> z10|XBUHo*>4~|tjvq#x`b6A}|)+>sF;{Hj$8XGA~-#f3p;+x%A9Qb5XBdTPjS!PT1 z0SO{kJ6qeauglw;HBt-g?idDUDW%>~Vj@yBz7mc0rj{v~W2qD60TmU1=J`Ttw_8Ka zTN@w$AOVTx4guG!c9ApbIZ4j%DPI3gqK?_ODI$-X`-xe8y}0>1?~6%zO$7Qd=eN5h zk2%+GHd|1337c)FnOgrKh?)!4x4t^sQ;E@Kt6kPeKV%xC2g}SnoVY=uGX8b>jx9nm z<&1I1eDTs!Ou2bH_Roh4!|t`$o{dCBMa3SuFWR`CywaIp-$VUd3s^WL4Lh5^O1n&! z?_d*dZYVy$>~d=1r>^i5io}GFd9EV4xw}@s@X?1~48spPeZBFw-UlmNj;W|G_pIgR zDSA7@6-=6Dy-cAHRYC)X3{cjMW>mzM%miVs?flYoX6UckWwtN{N8{5GtGa@x z($Z}%=daKXfe(}jq^o4kn95%mTccqi+pL+_m{_lDwp>tFdw%zH>au` zg!nf_AC)rRj`QL+q4xCey)Xzm<2H#na5QeG?dK~mDd|sEx=_LlclXh=5#I|QpXhvg z(CqJP!ZczI5uRCjZzlx@@mSlB`Pu$bBFD4JmApjV4$tf^@rWz>r=RU|lHP|Su3}E(rIhI2bDn?JB;E9?Jc+P*>1iVn~a;KGK*)SX$p&=BQyAF^jkFN zsGVra3E9AzN+&InICxu|fh90& z?SXLHiMae;0eNv>EPKe=Ow)&-O(OFNw<^8LF3zO;)>jPi?WYsm=W+(1iYAewyGiFChQmhAhMmowf-yD?P zG*$4nS?uRWgk9dYan}%;RxUZP|B118h``YN;zbbvFI`xDH;>V5RorO&B9U*Cy8rr~ z&HP9OEC*#prz!ZKz_5wVKpsV|`Tp#>bE>C}4B1Hy`O^lh36`=&oT61x*iXb2! z5xhlwcBC5q(k|I`)FUL*!@t!19O3ownSa z$(>2ErHvV1p|2V)Bt;h`&ToA)_$Wz;h`jnP=N1Z#rYr7|)3K>e8OhaI(mhXX zyKTmWPV31&KNy_=X>%<7rk-WYP8WEN?tntGETifW&PVE4(L{L>Hds-nSAKk^$y>B!Vt@ZSNh}NO)CHh-+IX^}52& zgMyw29uhzAcArOEta&LH#{5ItSy|uFq;h|Xgi-h~l;Yy}QKz}yCw3DHJ-^+F4{NlA zJCW=)`(IUjr3Zds^-UL_kPR~*B-Wx^QTq-No`sYJmZo(0BQf6M~0Tm^bHVx z&xLyYEZD0xkak&cQqF$qbWRq-ve#Hn$X<Ap~PeN`s$gW^NlDn%!EUo8&g;XKV-Kdv49>ZLTUXKga91I60R z0tlrf248RCQQr*=HSE^>W;uxqrFJ#uX1ckaPykpunNT$Z0$gHGeko`exW*E4kN zPc2?~qIvuk63T|jo6DY&gv2_K9jRUxlT&`;)hq)?IIpEfw8SoLYA-MFJfIibM{Z~+ zKNKugEwv=_{CcaWqkeZL=7{|SbiH+~8n2V>9m{wSPpS(96(>h}u*lr`pLI-c<5VOp z-TYOR{{d1Uuk&*a^m_~ozkAg?Lw_4rQcl;Nii343dUx00C} zG&a-}`$-=^b#L6R?Sw@@rK>gfDd zZ=%SGf7(zK$tLFiUQkF*o-Yzi?%eesg@~_`wr;ALy8xw9wTdeB_k^J56+CL6s z&nif%^rqb$+-?sCcArrPE^o@+vKq+yf&cCx^i7$8AyKi?@6OZPiJvno_FAS-Z_6gu zj(xR|PB0pMx5820&KMOtJf!tj;8~)MRN(T4>UlULPg;YAk(@s-_RQOptF>;n++XC< zv{t@(aW=WTlZO0DS@YgZH=V3jMAok;mKWDkQ#X6eG^2#FI%b#Aq}h~8`tvG+yZ#UpT+M7@-6zU?*4xH;qL`W*r=Bm18vra3t(od+!vBeKejf~V8yUZg4 z182+)$6dLnK_pe>xZ!$v;W2$BF1ojonF_{r;2-J&00z(;Iqxk!fc6}T=>{trh;=?+%L&L}2HgHkHJOrU>tezVA$@ zVW>f#AmqetEh#FAxcAuzgS*X;Ye+RY&nckaT$XshOLuMVPHudKs(%?O-kTHrDY0F} z#a~e}N?Mn~i4ocBR%F+)tw6Hp7_NxSI6_8GNOXv&@Zpn-voK?jzgF39UruV4bIQ? z&JIaM@||Tr#1m9i6_07fdvvUX`=ft0*hAi>ja98*W+Ko^?Hlz(|)baf02>s zMJ`yyX;s?adU1Ynx&_wJ4c;l2Lhi-0-kSbVVlP;Wj7NT=eD$2X_T$A#XlVBbfs;Ch zuY7v7i`(-Y5}gs_C7*Rh7<~KlJV!!1@`y6x>Kc%}8?v{%X5ym6j7^i~h(y<>DYzy% z*YX2irFlu-Me8(2=}<0K@i;%chNn}Q@%B9W;rj=5m?~KFFOUn}A|pee5WK;%Liea- z^2uCoW;4{NwcV?6Qa6^m|D@`%mmR^`eZ!ioI5K`i(Bs4^y*rcJkdE;0Qx-ZrZ9Cc$ zeztjhy-G0nJbFAflJ^8LBj248MvK=wNk_3hA(oE3v+my-|Sz_@V@j~J;~jhVpBNBx{{g7 zA7}~ykggmQj9*?=bz+hmjG`AfcLjmn?%`_MRF22?Yy%jO!XH;o!WtS;c38o9ik54o zfM>xZ5#TZ(yVGPm32vku_wUO+7qpn*LJ6oNG6&B|(0j>(6S)@vozkSjb#opDbH^GX~9XfW(h;vxl%U8a*5Otf&x zW(L7S4^M8?h0B9IcAYt_b{u$-o{Y9OaH}nEpGgs$=~EC|ixo@Yu0{m~1HW8~x2<%J zWY2V?{l-|TMt`l{@^gzP59)`$EZM(6PPLEBIJ0_$tyQQk#-9xKcEGSfo7d|;Iy%ZL zVCv}Kdw(#vw$zhwn?&HQ3+j^h9Qa5_F)DV(sR4jw9gb!&7G`~V<+}#lY*h(LN)C#W zLR8%SyOITd`T5UA4sktMC2yUW_^hiaSKqX=E6%G@0-IuvX!B}&amRk|*UMG5Fe|0fz5RIdK$21~M=)2$6f+(yYl%6}4?k%=tG<-z>bWneSxC8MIQ z?kAA*M$Q9iIcM0^1yTA{M@L7TqQ53ZNH*@fn@mhh8*k+-BcGL*Q(v{qKV5#9@^tG0 zF&76~JGso{Gek#k%Xwm1(4Mvg7nhW9i5PgF9b+7B>!(Gg_Q$CiwA21+(FfW_t_!5S zUa9j3+vKaBeB_gb15U!CubhvsUKf5&3jTX*{olX$Kl4=hzvZsGBCeV^fh2T>Jw%-H+zl_8mmL;z%i4hm*PK&^{*Liix5xpUX`0hqJI^YgLSw+mQ+bn~zqW}tM*l<;_5J0vRY>q=hG7Tpi?6aWQ z-@okswW;XLDB2keg0rtv1_2Av2N|8*xc-_g3cvYT9(X*oK{OMU3KrYq@A<$<)eu}- z;Vn@T!AjQ)V&m$_evyU8qQ(U~BsS@fbOLLDh#aO~BS5r=L!jSDuA`C^zAYXr&#dw7 z4nq?h)3?p?fPDl0p5O?KI}TR31bYR5Iz@Yi3o>r7LRF6c&ldtkPCUoW9tRu||MozH zaQvY-0!m|iVOM*wXI~fs(&_V$d^Vs~zZzaL?N31vM-jpVoDvOu-mS5(R57~)Qe;=| z(D*ohu!#Vg1S-xC1qE9`#?{l+T`&hP%V7SrLckt?yjm*tX+~on=-^+^0holR|EtaX zqq~&!SA4D|AOu_FiL0p*cCwQ(U!R9vtLTIYJc!S@OF;P0nsm_6JENIp*I!?g}mfxTYha-BJ<%;PC z2Zyz_wX3V^g9r6+Ce@q2VplyV`KVO0K@Z(hXQ%x%AFl5 zaljS~?zhh@geMLM*xbPM^s7hM;&vvFY;8G^;jk~4MBMOV{sOXgdY}-p0sDph66|@2 z#~Z*@8qC%?Ouy3uxg})0P)A~eID$`GoY?@#ix85>i+WuEHQ@~{aRoRv1H4u*(X`?)baNKISx`hv$6EGd}Gy5B+g&z>mLXK zw{aB@K453;$HYW-Z^LS#9|-c|Uxx+8af;-k@Ej z$?C94^;*htS1F_vb^2s*6P6JH3eI~(cxoJ&5&FxK)^PTMjcp9Q{B@URcxGQTYZ$vq zzlwuh?=}c-<8)SG^??;XKA?ew6cNDpc{D`~F&&@2Ay?J@BrpZx$r~90eT&lNr`Hsa zg}|-k9;h8z@7X*8gKR@hNGc$i=~!d_ce~q0YTV%hr97qCSdl@nB}Ec^mGE$Ro7%yU zU)XL*#@w9QAEu*#Zc5$-h-biMbige+fJCU*4hlAg5Mv z=mIA}h=#;^a|Aq)gog`usVB{OY0p`>ha09}2n+7z` zZP7Dt!wN?vPEJmK?0wXVgND-$br^|+H_6p@>zfuUp7aI$JTwqel zt0z7?I|IthSc;f0m~<0qh;<`ghLoCqEY=u)} zp|FhZaq23rf?&#|dOADY8ge?2zjQwWkl75REmxk(tE&usSjLlO`Rhd3IFF>H1O)|+ z9^+{GJk1b~m|IwgN)@7^xiRN+aRweg05@Y{3H{}Y6W#oglbSqgZw@0) zAF|k|*x&z%K7;-IT5`)ZFL7Z8_v82bhBEPzad#-aVPom7LMU`a(1C*(5O`nQsBV2S zZ%p}zMj0sMBPJ2%+d2b=zxsW_V4qxET#O?*9{w+PIUdOunNs1eXVUw2xW@#q|9dn2 z7XzjL;k;bTzmMIvaDU%kj9-?u=5WER{@-^*Pxo~6X87YOJ31Ch4wh#u^a1zF?^u3W zqInuuRTZCLa&r)7)Bf3N^lf6z+bfZ-U1d?~<--PDDocG?9ij1YSezxtl_D6 z`6QTUOXVK3aYLTiJ@_bBKc7C{U})-iksTaM?XaRPXq-}$^3n8gCaR$J$x&f!z7`9U zA%@yt^C;Up;3dYI`9aYoBqE}pZ#Bs!Pj8{u+wG^cJ>l(#>LoEjdGXsywR6d(hRkf2f4;B4!2cF}f%{{phd=AHR=s3@ zCB?V*nf^qCA8D$5{No8v7XJh*cP00WA~-&JMfksd7@)AcUxFpsdJFZ|ZOmH$=iOYL zyJ9b{gqFGF+G_lo9y!Z@d>Ljgl&15>-?9U;6pYN9XD1;^KPq@>c%tT{xgd1DluK1UD2m&CbnPD z{%rXb8xY_d-`tW4C&j%igL{VCxPLYaZb#6aLqP%Vl@&0E5{nX!U!T4yaEoq5=_H%IlmN8L2?MfOYGw%!Fg*^rY4nSxCR;_EGuUegF0F91I{Z}dqa z8{jW~uVE&O$MpN-GdDao&5#dg;EE3L#)cudYg!bRN1&~w+H#5xsc72KCgcCB zEAn&Vv2~LKGQ{LCQnfEK*|!Ct?KBEsIXbw%OK&q4q{E{9gpSAdOvshl;Y=j}y9MK8 znCp3^>A=rsvdiU#h5c|M>&>xv+r1dm*2CwOepFIzJXWUDzcyqFX1_VUihf7QsN5N2 zxH{iv;p`nN!Xi=OtCVu;|rq#cJ1rJn_ zAAtXYtB_gp1RS}4z>}?qTnkte0E&rRyh#OxzqR>!-wX{PVc-FgyB6*<`t^adHlP+5 z%)uq8US=L79^$psO%MfJ_Z-45_*PqmAVMX(dp9bT7G?^Ggk4Ky2vXoHzd}~2sKoVu zw0j*3eXbCKE8h3;KgcZrU~xxvnL^U~Nm6j~>W+X)qwNH&wrBAiH@Ylls;|&ci#H+E zk?>=eknyvZ==Vwr!Nns{nv+$hl<9m^e#yVAzNd8?fdXqUwhRRJn2Rrl(?rh$+P*ef2*c;Yx=fQp}$e;&M#v==q> z1!{k|^~4d(AtZuIn$$kgyW86jdVTP9p$7?#Zl4Q1JQ9W4wPeR@E-EyFzLKfCSsjE6 z@tCwhL@@;#KkVuQKh>6kdhYjmw-ykCO4-i8ntF`R__~{M9UG4w1uK8iK|0GwPd7X_ zHCFjXtdu+CBQCzUoxwCJdSrbwX7I;b-I>3&fNzS+!UD=T#KJZ}Zo;|${$T4+(BVYL z&@Ylrvo_|T-ytdATboRo#IHJCy)heAdP$#KH>x_@^m_~HWG_y<@NYWf2ih2RC(+UI zgLn6ABu>|;`9yWk>5$Zvu zUHB4(UdVYtvW=9igWzDDw_2)Br-&oYeYmoxh*;0_VDS)R=9jlA`msJ`4s};@Rglt` z>5$4);vvtI;}WQPG4u4ow&wWQ)t}=cJiP~I#0meH#W#%GbsAs+w9^|phfm0q z@?<{n@mn7B-HMF#x?DoP^(28;bZ6*^jn@|$h3q2KyJZ`XHTT_q9*}gzbu|9KArnYgY5}G%iSO+8C=Di;x_&wzNU6hZmWHW=b zSH^0iQnF{bWXaw-4uq7txy+i+o+;{x;6J~&3cO6)qo&u%l9G(eWXC^lKR7*GI&M-y zn%J8@#f;|U_u6?Ht4Y>1r;=DGq4n}5*U~DeSKo<_w7&y8ri7F8^_D$PNs6sdtnm(EDO#G9 z-|YmJAL)^zT&mX^*zK$QYLU(!&QVLXzG!SlyQk=ImZzYoJHM2ryiV!yfJlUap+-!2 zZPMvG!tLSS-uN>6>(>W+lcZZdc14%M9Y=HjzQwK)=EGpMj{+7kQPhlzB_-v&62#tx zDUTd-bszSWTgZmJeR^6LJAIDwOVtj0cbT_9VH|t)fxUfY^zaW(dk5|UjgGRIN1r!geWT0TLpE<@3~!=E?|kYREQi{9PAkw-6?Fq`Ua_mS*kC`CNdE=b|na)kx`}cvb z8shtJ_4%nfjy{4jItE5d3BK`o5e=m`AaY3ftRJXj@O!uDU_ve8y=?GZ21>3`F=M8@ z0{hiLyuQlCHgWJ^VC9{M9DWk=YUWDFh2YIeO;E1Xi8&%>Kf~krSS;7lCLWf8gwdya zbEl|ZDVCv$jkC_9>f!t;Lio&TKMu<>xQT*1sjM68WPe@2vAGS5MiBkOgzYxpeZ0@j z%^j?a0qv=c_SEyOshS@}uXu%oT~0KbTsng?`_Fb0Dt6`D|&=hRohFJjZd9{Z2#k zNTQoyiG_j84JiL7SGk*lF7Yw?^5uomTFf)`xq^r9!OP6GXM&25&u;UldJp07?_Bv> z!xtfS4%67O;b#PfyLGC2_i6F=+dlF0jPMM39^EP?JnLxA%{nxy2`bx|3Yc4cA-eXx zmRsp~ZObSlJqKFeyA@TdR`0bXc3xlbW5$PGIRjVR7<{wJiB1+5FCr10s^3i?z+8mP zRk1bm!fC@?y}pUq;z%MOIz=o4`!N9_Z_(yKZ9XwZ&^1zaLv!U%A7&``=U| zX}EN0pIbk$YwPVbKMh-GPPYf|=jQjHOPtgg68YAr^7V<&PbYNn(=4WwrmC{0gK)Cr ztOq4g1l5c2ddwe%h7KprjcW0!s57m$w$B|LI2t}?0?9lnC+k7)D{e-O+G-k}UO}r# z6*Wc=mCo$JK_mtL%+UNFTRC}{VIN5N;(SsXv@yQQiLpWEf$S3fhWOEoaaoCKW=V8s zNmTDPDA;`3sWTa(ZAJi);!TpyD0xp_I%;ZQ$fXj5X9ZF$yn%U;t4IaHd{}}oG|p>2 z7NOI-!3FJvYAB9EJppPOK+19W4uEy3uxj`PR7>H7GiwwOBiK?J>Y$dWrKKelorO>S zBvRHvN8~f<5_2w{paK2M&k@zU%TR#5&&;JP1B6zo=RWx2g8U=WhB%KO0FRLX2gkSM z22&Vr)a2_6L49}fK8^bN<5SaAJ349A&-UXWGHzn?5NL|d?JPY3Y=A_kH$rmb4L7H? zvpuU|hz!&!#QFJq5>bxts%7Iwi z)VH$K+FG-hx*(Vn*t5j??)`E_&I6gDQJe`g9$78v1(|7Bo*O6m6Qwx@_$>6)FOz6z zH<#Wn>edg)-^yWoRdF#>WkvMjwY&sM6w50GOs}arI#LPoCi%;6Mn8Gk6@^8&f^YENd8L{Wi(?ywKuxctyd!>Yh$TVq*i&vU| zie)@r_cnfl1`5wOQBvs`xz3&U>BjQGD7T9$*WLsNSDY>WT+BYxeD)mMvFs9G?77J7 zd|d%L#%)RTZcs35xE0p2JiTgPX6eLE%4(1at61WHq}TAr)G3?@b}5r;bP zRZ*0m>c-K0xM(3jtqdUqK(zyhnxTAjOuehpI6!X!L%mf4deTMR1!xAPiV);jSX$0N zCDf|!9PjmY>;Sgfqw7Jw)l1i9BT2{$9~2cDbm47mFy3K>E<_fLo&)<(70!NO#zEuf zYZ$zVm5I_^4G$tBBC$(?#~5MHi-s_;7HF`4*FKG+n$7NiLa?;&eq7#`FZi@~asI_) zb%pf8a=FUuwIq2aLV|o_^L%yg*$*!21i8CU6n$YlbivyQ;dbgRq=D|3SZe15H-Y@p z(&z*;&5hFAs(btU5s1dldW_wSEYtM{4ihr^_5C)Li=$RkJVn=MG;fqN0r7x9!1dkw z?M7!fvJq}0vdgtkO2=q-b=;V)aH@MemlbQUYDFm&cB+5J5hvzov0N0j2bVR?-(z2F z*%A5P!s=Y@^tX$9&H~VQkhhFgb>E&sqv05kX_HuO3m}hfYx&>}V~6DO`Vqv9zCJ!szs1GEdVLFvbzvY5 zO7*-y+&N9&**^OXB}2#x1Ac6_UkBbgWfbqn$txwo!2LZ$6wLN8+&dWLS?zLUC1;9^ zxIdA$}A4aFBftV9mE52Z?|E~7pBUgW%^s|u*beP|V5)dH^g@`LF? zWuwS%r~+T!=k3N(y=Cgt2t-Rg609`st5pfzr?Qkwd<(7c32Ev#OV!957;KAa=p>qI zsXkAucL+26&vW`cHacc$jJ52Upp`JHTrMyBr-Xq@ivnc#NCvS@uN zY1oO*)9D+4T8aUWgo`@rRVN_bo+=uTB=N#ic``FJdFF%8S@X9hDTNPQWxdG&m5b3& zzm}b?JGzf_78lnON($8{N2`6-{#%!Da3R6^?qnsxo{M6&4L`&j^EIjBj@ z4nWJx^DQsqM)$vLe@#b%TU0<(#E<%{2oL^$L)v(AkdOV!>HfxQ?B6jifOjCVZJd1pWQs3pd ztp4uCMD=i%k@A^h?jaBM+xj-K_!}6Cj4@*U)|y#b{Eos;it$X!&Qj({)q~C2T%v3KK ze+lrbt4}0cXc)bJb`kvt#5=qJzJFml+oM!hf{IjXu)WK^%L{7|5lHieZ|mR#zX znjB(XkG(?|~)@c@5=C9|1g;q{T80t=& zRr8niE&SnAO7Gjk3k%y#l_41Vc(k@Ymqw?YH@)Ah4hgp_^V*Vfv6|s~XV-7a`6|dJ zK87#O=DTko-4Uah6H2_QeU}J0m9!*Vc2wnCY|{nQU5>C*P>^hRan)fVv^V}5&fC6s zFL`rC`C0b^{rgKE@c{BTL>~*thrRwaPBr zdTcV;nyJDbEFG(;q7>PPM__o{%GfvO z5?il09`NmTSx!Pcy-gyalXg;C_kjyJ)B;_3o5fD|GLx$9Qe3dQ+A1D(v6IU=7qPRa zr@PpbdyQJqFf&Eq^gjQlTROFIy$UTpru`_CYMN5Z!MYGe z&OUD$!Sb9PvB0%42Td=QxUd=c{!oD%? zg)bdbfO+~&_OUS^Py4NBVdQI6Mv55<$BSso-EqQO)tRSHs0qpJO-HkgMwcx=k!3xL zKO4ThgM413O^^gOrYDnZv?&vmcJ24Joh}AL{knMH8cnmepQ@IbYKGTvg2E(nXMF?X zlD?A}E|^W1N&Jyabn#X0Zjdx>By9bZ4-@+I7z=IO=#7YleuS8zhtb*r(-T^wn4~1u zz?JB^wDf*wrf#uQZ*i^DSmVj5_>_w(yL5~74Ywwmw%CmJlzLv#sny*g=}2oJ#Qz-T{!X*LNk~g%@1y%e ziMnuI&+~DSroP_Ty*~}WCx*WZW`P(~5mLQ_gj~d6a4r1o(m3#EioisZaRA$~UZ_@2 z&h7)@PPf`0I~4_leJ%XAm%Quzq?JCX$91KhTc~k+{f-h7TFVoQy)f7Ma$Pl9E}~_F z&Tq*R=Lm=G=3Z2yWi7qzBXqcJFA1z3HIt{NzA8+4*x=)1iv`WC3Ymk%_6WZ|x-dqC z&$A;WZn75+y*=Dk#jU?ZR;Fs7G*z}%o2HLg@j{C+u}-BT*QeipzrWh%d}g{TaCA|t z;M!FSYarn@jBUranpN5V(J$GjFMhqPiG{hl%D(8#3O>Ttc0>-c|qf5nZ{DT;emaNRu27HC^@fD+NL=%0D(X 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 X$FFCtym{*lzoF1l*HtT2xpwbgw^?4^ literal 0 HcmV?d00001 diff --git a/.github/screenshots/before_no_org.png b/.github/screenshots/before_no_org.png new file mode 100644 index 0000000000000000000000000000000000000000..9a4cfaa1d879bdb1b7efa9466f5aa9daeda33231 GIT binary patch literal 97528 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

cNq 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

`@CHMR4gaYfrFVyCIsfE7^tzB%)RV|tk#b{!M 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 None: + """ + When moving a team to an org, ensure all team members are also org members. + + For SSO/Entra setups without SCIM, users join teams automatically on login but + are never explicitly added to organizations. This silently upserts missing members + rather than blocking the team move. + """ + org_member_ids = ( + {m.user_id for m in organization.members} if organization.members else set() + ) + for member in team.members_with_roles: + if member.user_id is None: + continue + if member.user_id == SpecialProxyStrings.default_user_id.value: + continue + if member.user_id in org_member_ids: + continue + if organization.organization_id is None: + continue + try: + await add_member_to_organization( + member=OrgMember( + user_id=member.user_id, + role=LitellmUserRoles.INTERNAL_USER, + ), + organization_id=organization.organization_id, + prisma_client=prisma_client, + ) + except Exception as e: + verbose_proxy_logger.debug( + "_auto_add_team_members_to_organization: skipping user_id=%s - %s", + member.user_id, + e, + ) + + async def fetch_and_validate_organization( organization_id: str, existing_team_row: Any, llm_router: Optional[Router], prisma_client: Any, + user_api_key_dict: Optional[UserAPIKeyAuth] = None, ) -> Any: """ Fetch and validate an organization for team update operations. @@ -1294,14 +1340,25 @@ async def fetch_and_validate_organization( }, ) + is_proxy_admin = ( + user_api_key_dict is not None + and user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN + ) + organization = LiteLLM_OrganizationTableWithMembers(**organization_row.model_dump()) validate_team_org_change( team=LiteLLM_TeamTable(**existing_team_row.model_dump()), - organization=LiteLLM_OrganizationTableWithMembers( - **organization_row.model_dump() - ), + organization=organization, llm_router=llm_router, + is_proxy_admin=is_proxy_admin, ) + if is_proxy_admin: + await _auto_add_team_members_to_organization( + team=LiteLLM_TeamTable(**existing_team_row.model_dump()), + organization=organization, + prisma_client=prisma_client, + ) + return organization_row @@ -1309,14 +1366,20 @@ def validate_team_org_change( team: LiteLLM_TeamTable, organization: LiteLLM_OrganizationTableWithMembers, llm_router: Router, + is_proxy_admin: bool = False, ) -> bool: """ Validate that a team can be moved to an organization. - The org must have access to the team's models - The team budget cannot be greater than the org max_budget - - The team's user_id must be a member of the org + - For non-proxy-admins: all team members must already be org members - The team's tpm/rpm limit must be less than the org's tpm/rpm limit + + Proxy admins bypass the membership check and instead trigger auto-add of + missing members (handled by the caller). This supports SSO/Entra setups + where org membership tables are empty but proxy admins still need to group + teams under orgs for budget/model governance. """ # If the team's organization is the same as the new organization, return True @@ -1357,23 +1420,26 @@ def validate_team_org_change( }, ) - # Check if the team's user_id is a member of the org - team_members = [m.user_id for m in team.members_with_roles] - org_members = ( - [m.user_id for m in organization.members] if organization.members else [] - ) - not_in_org = [ - m - for m in team_members - if m not in org_members and m != SpecialProxyStrings.default_user_id.value - ] - if len(not_in_org) > 0: - raise HTTPException( - status_code=403, - detail={ - "error": f"Cannot move team to organization. Team has user_id {not_in_org} that is not a member of the organization." - }, + # For non-proxy-admins, require all team members to already be org members. + # This prevents a team admin from moving their team into an arbitrary org and + # thereby injecting members into that org without org admin approval. + if not is_proxy_admin: + team_members = [m.user_id for m in team.members_with_roles] + org_members = ( + [m.user_id for m in organization.members] if organization.members else [] ) + not_in_org = [ + m + for m in team_members + if m not in org_members and m != SpecialProxyStrings.default_user_id.value + ] + if len(not_in_org) > 0: + raise HTTPException( + status_code=403, + detail={ + "error": f"Cannot move team to organization. Team has user_id {not_in_org} that is not a member of the organization." + }, + ) # Check if the team's tpm/rpm limit is less than the org's tpm/rpm limit if ( @@ -1617,6 +1683,7 @@ async def update_team( # noqa: PLR0915 existing_team_row=existing_team_row, llm_router=llm_router, prisma_client=prisma_client, + user_api_key_dict=user_api_key_dict, ) elif data.organization_id is not None and len(data.organization_id) == 0: # unsetting the organization_id diff --git a/tests/test_litellm/proxy/test_team_org_move.py b/tests/test_litellm/proxy/test_team_org_move.py new file mode 100644 index 0000000000..2dc961bec8 --- /dev/null +++ b/tests/test_litellm/proxy/test_team_org_move.py @@ -0,0 +1,232 @@ +""" +Tests for moving teams to organizations. + +Covers the SSO/Entra scenario where: +- Proxy admins can move teams freely; missing members are auto-added to the org. +- Non-proxy-admins (team admins) must have all team members pre-added to the org, + preserving the original security model (no privilege escalation via team move). +""" +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from litellm.proxy._types import ( + LiteLLM_OrganizationTableWithMembers, + LiteLLM_OrganizationMembershipTable, + LiteLLM_TeamTable, + LitellmUserRoles, + Member, + OrgMember, + SpecialProxyStrings, +) +from litellm.proxy.management_endpoints.team_endpoints import ( + _auto_add_team_members_to_organization, + validate_team_org_change, +) +from litellm.router import Router + + +def _make_org(organization_id="org-1", members=None, models=None): + from datetime import datetime + + return LiteLLM_OrganizationTableWithMembers( + organization_id=organization_id, + organization_alias="test-org", + budget_id="budget-test", + spend=0.0, + metadata={}, + models=models or [], + created_by="default_user_id", + updated_by="default_user_id", + members=members or [], + teams=[], + litellm_budget_table=None, + created_at=datetime.now(), + updated_at=datetime.now(), + ) + + +def _make_team(team_id="team-1", member_ids=None, organization_id=None): + members = [ + Member(user_id=uid, role="user") for uid in (member_ids or []) + ] + members.append(Member(user_id=SpecialProxyStrings.default_user_id.value, role="admin")) + return LiteLLM_TeamTable( + team_id=team_id, + team_alias="test-team", + organization_id=organization_id, + admins=[], + members=[], + members_with_roles=members, + metadata={}, + models=[], + blocked=False, + spend=0.0, + ) + + +def _make_org_membership(user_id): + from datetime import datetime + + return LiteLLM_OrganizationMembershipTable( + user_id=user_id, + organization_id="org-1", + user_role=LitellmUserRoles.INTERNAL_USER, + spend=0.0, + budget_id=None, + created_at=datetime.now(), + updated_at=datetime.now(), + ) + + +class TestValidateTeamOrgChange: + def test_proxy_admin_not_blocked_when_members_not_in_org(self): + """Proxy admins bypass the membership check — auto-add handles it instead.""" + router = MagicMock(spec=Router) + team = _make_team(member_ids=["sso-user-001", "sso-user-002"]) + org = _make_org(members=[]) + + result = validate_team_org_change( + team=team, organization=org, llm_router=router, is_proxy_admin=True + ) + assert result is True + + def test_non_admin_blocked_when_members_not_in_org(self): + """Team admins (non-proxy-admin) must have all members pre-added to the org.""" + router = MagicMock(spec=Router) + team = _make_team(member_ids=["sso-user-001"]) + org = _make_org(members=[]) + + with pytest.raises(Exception) as exc_info: + validate_team_org_change( + team=team, organization=org, llm_router=router, is_proxy_admin=False + ) + assert "403" in str(exc_info.value) or "not a member" in str(exc_info.value) + + def test_non_admin_passes_when_all_members_in_org(self): + """Team admin move succeeds when all team members are already org members.""" + router = MagicMock(spec=Router) + team = _make_team(member_ids=["u1"]) + org = _make_org(members=[_make_org_membership("u1")]) + + result = validate_team_org_change( + team=team, organization=org, llm_router=router, is_proxy_admin=False + ) + assert result is True + + def test_same_org_short_circuits(self): + """Moving to the same org is always a no-op, regardless of role.""" + router = MagicMock(spec=Router) + team = _make_team(member_ids=["u1"], organization_id="org-1") + org = _make_org(organization_id="org-1") + + assert validate_team_org_change( + team=team, organization=org, llm_router=router, is_proxy_admin=False + ) is True + assert validate_team_org_change( + team=team, organization=org, llm_router=router, is_proxy_admin=True + ) is True + + def test_default_user_excluded_from_membership_check(self): + """default_user_id is never checked for org membership.""" + router = MagicMock(spec=Router) + # Team has only default_user_id (added by _make_team) + team = _make_team(member_ids=[]) + org = _make_org(members=[]) + + # Should not raise even for non-proxy-admin + result = validate_team_org_change( + team=team, organization=org, llm_router=router, is_proxy_admin=False + ) + assert result is True + + +class TestAutoAddTeamMembersToOrg: + @pytest.mark.asyncio + async def test_adds_missing_members(self): + team = _make_team(member_ids=["sso-user-001", "sso-user-002"]) + org = _make_org(members=[]) + + mock_add = AsyncMock() + import litellm.proxy.management_endpoints.team_endpoints as te + original = te.add_member_to_organization + te.add_member_to_organization = mock_add + + try: + await _auto_add_team_members_to_organization( + team=team, + organization=org, + prisma_client=MagicMock(), + ) + finally: + te.add_member_to_organization = original + + assert mock_add.call_count == 2 + called_user_ids = { + call.kwargs["member"].user_id for call in mock_add.call_args_list + } + assert called_user_ids == {"sso-user-001", "sso-user-002"} + + @pytest.mark.asyncio + async def test_skips_existing_org_members(self): + team = _make_team(member_ids=["u1", "u2"]) + org = _make_org(members=[_make_org_membership("u1")]) + + mock_add = AsyncMock() + import litellm.proxy.management_endpoints.team_endpoints as te + original = te.add_member_to_organization + te.add_member_to_organization = mock_add + + try: + await _auto_add_team_members_to_organization( + team=team, + organization=org, + prisma_client=MagicMock(), + ) + finally: + te.add_member_to_organization = original + + assert mock_add.call_count == 1 + assert mock_add.call_args.kwargs["member"].user_id == "u2" + + @pytest.mark.asyncio + async def test_skips_default_user(self): + """default_user_id should never be added as an org member.""" + team = _make_team(member_ids=[]) + org = _make_org(members=[]) + + mock_add = AsyncMock() + import litellm.proxy.management_endpoints.team_endpoints as te + original = te.add_member_to_organization + te.add_member_to_organization = mock_add + + try: + await _auto_add_team_members_to_organization( + team=team, + organization=org, + prisma_client=MagicMock(), + ) + finally: + te.add_member_to_organization = original + + assert mock_add.call_count == 0 + + @pytest.mark.asyncio + async def test_logs_and_continues_on_error(self): + """Errors must not propagate — they are logged at DEBUG and skipped.""" + team = _make_team(member_ids=["u1"]) + org = _make_org(members=[]) + + mock_add = AsyncMock(side_effect=Exception("duplicate key")) + import litellm.proxy.management_endpoints.team_endpoints as te + original = te.add_member_to_organization + te.add_member_to_organization = mock_add + + try: + await _auto_add_team_members_to_organization( + team=team, + organization=org, + prisma_client=MagicMock(), + ) + finally: + te.add_member_to_organization = original