From 667481e75b0196be0e2547c4857f23a2d89feb3a Mon Sep 17 00:00:00 2001 From: xprilion Date: Thu, 11 Sep 2025 00:07:30 +0530 Subject: [PATCH 01/44] (feat): Add W&B Inference to LiteLLM --- litellm/__init__.py | 8 + litellm/constants.py | 36 +++++ .../get_llm_provider_logic.py | 10 ++ .../get_supported_openai_params.py | 3 + litellm/llms/wandb/__init__.py | 0 litellm/llms/wandb/chat/__init__.py | 0 litellm/llms/wandb/chat/transformation.py | 27 ++++ litellm/main.py | 22 +++ litellm/types/utils.py | 1 + litellm/utils.py | 27 ++++ model_prices_and_context_window.json | 126 +++++++++++++++ .../wandb/test_wandb_chat_transformation.py | 146 ++++++++++++++++++ 12 files changed, 406 insertions(+) create mode 100644 litellm/llms/wandb/__init__.py create mode 100644 litellm/llms/wandb/chat/__init__.py create mode 100644 litellm/llms/wandb/chat/transformation.py create mode 100644 tests/test_litellm/llms/wandb/test_wandb_chat_transformation.py diff --git a/litellm/__init__.py b/litellm/__init__.py index f6be2bc6f00..50249250755 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -60,6 +60,7 @@ from litellm.constants import ( empower_models, together_ai_models, baseten_models, + wandb_models, REPEATED_STREAMING_CHUNK_LIMIT, request_timeout, open_ai_embedding_models, @@ -239,6 +240,7 @@ novita_api_key: Optional[str] = None snowflake_key: Optional[str] = None gradient_ai_api_key: Optional[str] = None nebius_key: Optional[str] = None +wandb_key: Optional[str] = None heroku_key: Optional[str] = None cometapi_key: Optional[str] = None common_cloud_provider_auth_params: dict = { @@ -520,6 +522,7 @@ cometapi_models: Set = set() oci_models: Set = set() vercel_ai_gateway_models: Set = set() volcengine_models: Set = set() +wandb_models: Set = set() def is_bedrock_pricing_only_model(key: str) -> bool: @@ -734,6 +737,8 @@ def add_known_models(): oci_models.add(key) elif value.get("litellm_provider") == "volcengine": volcengine_models.add(key) + elif value.get("litellm_provider") == "wandb": + wandb_models.add(key) add_known_models() @@ -828,6 +833,7 @@ model_list = list( | heroku_models | vercel_ai_gateway_models | volcengine_models + | wandb_models ) model_list_set = set(model_list) @@ -909,6 +915,7 @@ models_by_provider: dict = { "cometapi": cometapi_models, "oci": oci_models, "volcengine": volcengine_models, + "wandb": wandb_models, } # mapping for those models which have larger equivalents @@ -1246,6 +1253,7 @@ from .llms.watsonx.chat.transformation import IBMWatsonXChatConfig from .llms.watsonx.embed.transformation import IBMWatsonXEmbeddingConfig from .llms.github_copilot.chat.transformation import GithubCopilotConfig from .llms.nebius.chat.transformation import NebiusConfig +from .llms.wandb.chat.transformation import WandbConfig from .llms.dashscope.chat.transformation import DashScopeChatConfig from .llms.moonshot.chat.transformation import MoonshotChatConfig from .llms.v0.chat.transformation import V0ChatConfig diff --git a/litellm/constants.py b/litellm/constants.py index 75c25d9ea9e..7f9e5190e79 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -311,6 +311,7 @@ LITELLM_CHAT_PROVIDERS = [ "morph", "lambda_ai", "vercel_ai_gateway", + "wandb", ] LITELLM_EMBEDDING_PROVIDERS_SUPPORTING_INPUT_ARRAY_OF_TOKENS = [ @@ -445,6 +446,7 @@ openai_compatible_endpoints: List = [ "https://api.lambda.ai/v1", "https://api.hyperbolic.xyz/v1", "https://ai-gateway.vercel.sh/v1", + "https://api.inference.wandb.ai/v1", ] @@ -489,6 +491,7 @@ openai_compatible_providers: List = [ "hyperbolic", "vercel_ai_gateway", "aiml", + "wandb", ] openai_text_completion_compatible_providers: List = ( [ # providers that support `/v1/completions` @@ -504,6 +507,7 @@ openai_text_completion_compatible_providers: List = ( "v0", "lambda_ai", "hyperbolic", + "wandb", ] ) _openai_like_providers: List = [ @@ -754,6 +758,38 @@ nebius_embedding_models: set = set( ] ) +wandb_models: set = set( + [ + # openai models + "openai/gpt-oss-120b", + "openai/gpt-oss-20b", + + # zai-org models + "zai-org/GLM-4.5", + + # Qwen models + "Qwen/Qwen3-235B-A22B-Instruct-2507", + "Qwen/Qwen3-Coder-480B-A35B-Instruct", + "Qwen/Qwen3-235B-A22B-Thinking-2507", + + # moonshotai + "moonshotai/Kimi-K2-Instruct", + + # meta models + "meta-llama/Llama-3.1-8B-Instruct", + "meta-llama/Llama-3.3-70B-Instruct", + "meta-llama/Llama-4-Scout-17B-16E-Instruct", + + # deepseek-ai + "deepseek-ai/DeepSeek-V3.1", + "deepseek-ai/DeepSeek-R1-0528", + "deepseek-ai/DeepSeek-V3-0324", + + # microsoft + "microsoft/Phi-4-mini-instruct", + ] +) + BEDROCK_INVOKE_PROVIDERS_LITERAL = Literal[ "cohere", "anthropic", diff --git a/litellm/litellm_core_utils/get_llm_provider_logic.py b/litellm/litellm_core_utils/get_llm_provider_logic.py index d5009fb0ca6..8d515e8f500 100644 --- a/litellm/litellm_core_utils/get_llm_provider_logic.py +++ b/litellm/litellm_core_utils/get_llm_provider_logic.py @@ -252,6 +252,9 @@ def get_llm_provider( # noqa: PLR0915 elif endpoint == "https://ai-gateway.vercel.sh/v1": custom_llm_provider = "vercel_ai_gateway" dynamic_api_key = get_secret_str("VERCEL_AI_GATEWAY_API_KEY") + elif endpoint == "https://api.inference.wandb.ai/v1": + custom_llm_provider = "wandb" + dynamic_api_key = get_secret_str("WANDB_API_KEY") if api_base is not None and not isinstance(api_base, str): raise Exception( @@ -769,6 +772,13 @@ def _get_openai_compatible_provider_info( # noqa: PLR0915 ) = litellm.AIMLChatConfig()._get_openai_compatible_provider_info( api_base, api_key ) + elif custom_llm_provider == "wandb": + api_base = ( + api_base + or get_secret("WANDB_API_BASE") + or "https://api.inference.wandb.ai/v1" + ) # type: ignore + dynamic_api_key = api_key or get_secret_str("WANDB_API_KEY") if api_base is not None and not isinstance(api_base, str): raise Exception("api base needs to be a string. api_base={}".format(api_base)) diff --git a/litellm/litellm_core_utils/get_supported_openai_params.py b/litellm/litellm_core_utils/get_supported_openai_params.py index 86535943762..d60e79069cc 100644 --- a/litellm/litellm_core_utils/get_supported_openai_params.py +++ b/litellm/litellm_core_utils/get_supported_openai_params.py @@ -151,6 +151,9 @@ def get_supported_openai_params( # noqa: PLR0915 elif custom_llm_provider == "nebius": if request_type == "chat_completion": return litellm.NebiusConfig().get_supported_openai_params(model=model) + elif custom_llm_provider == "wandb": + if request_type == "chat_completion": + return litellm.WandbConfig().get_supported_openai_params(model=model) elif custom_llm_provider == "replicate": return litellm.ReplicateConfig().get_supported_openai_params(model=model) elif custom_llm_provider == "huggingface": diff --git a/litellm/llms/wandb/__init__.py b/litellm/llms/wandb/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/litellm/llms/wandb/chat/__init__.py b/litellm/llms/wandb/chat/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/litellm/llms/wandb/chat/transformation.py b/litellm/llms/wandb/chat/transformation.py new file mode 100644 index 00000000000..1cb2ab492bc --- /dev/null +++ b/litellm/llms/wandb/chat/transformation.py @@ -0,0 +1,27 @@ +""" +Wandb Chat Completions API - Transformation + +This is OpenAI compatible - no translation needed / occurs +""" + +from litellm.llms.openai.chat.gpt_transformation import OpenAIGPTConfig + + +class WandbConfig(OpenAIGPTConfig): + def map_openai_params( + self, + non_default_params: dict, + optional_params: dict, + model: str, + drop_params: bool, + ) -> dict: + """ + map max_completion_tokens param to max_tokens + """ + supported_openai_params = self.get_supported_openai_params(model=model) + for param, value in non_default_params.items(): + if param == "max_completion_tokens": + optional_params["max_tokens"] = value + elif param in supported_openai_params: + optional_params[param] = value + return optional_params diff --git a/litellm/main.py b/litellm/main.py index d7395eb1457..ddc30637d4b 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -1968,6 +1968,7 @@ def completion( # type: ignore # noqa: PLR0915 or custom_llm_provider == "openai" or custom_llm_provider == "together_ai" or custom_llm_provider == "nebius" + or custom_llm_provider == "wandb" or custom_llm_provider in litellm.openai_compatible_providers or "ft:gpt-3.5-turbo" in model # finetune gpt-3.5-turbo ): # allow user to make an openai call with a custom base @@ -4374,6 +4375,27 @@ def embedding( # noqa: PLR0915 or "api.studio.nebius.ai/v1" ) + response = openai_chat_completions.embedding( + model=model, + input=input, + api_base=api_base, + api_key=api_key, + logging_obj=logging, + timeout=timeout, + model_response=EmbeddingResponse(), + optional_params=optional_params, + client=client, + aembedding=aembedding, + ) + elif custom_llm_provider == "wandb": + api_key = api_key or litellm.api_key or get_secret_str("WANDB_API_KEY") + api_base = ( + api_base + or litellm.api_base + or get_secret_str("WANDB_API_BASE") + or "https://api.inference.wandb.ai/v1" + ) + response = openai_chat_completions.embedding( model=model, input=input, diff --git a/litellm/types/utils.py b/litellm/types/utils.py index c6f7098a1a7..aaf5ff3bf7e 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -2356,6 +2356,7 @@ class LlmProviders(str, Enum): AUTO_ROUTER = "auto_router" VERCEL_AI_GATEWAY = "vercel_ai_gateway" DOTPROMPT = "dotprompt" + WANDB = "wandb" # Create a set of all provider values for quick lookup diff --git a/litellm/utils.py b/litellm/utils.py index be26405b43b..e3ee9532f46 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -3240,6 +3240,7 @@ def pre_process_optional_params( and custom_llm_provider != "openrouter" and custom_llm_provider != "vercel_ai_gateway" and custom_llm_provider != "nebius" + and custom_llm_provider != "wandb" and custom_llm_provider not in litellm.openai_compatible_providers ): if custom_llm_provider == "ollama": @@ -3977,6 +3978,17 @@ def get_optional_params( # noqa: PLR0915 else False ), ) + elif custom_llm_provider == "wandb": + optional_params = litellm.WandbConfig().map_openai_params( + non_default_params=non_default_params, + optional_params=optional_params, + model=model, + drop_params=( + drop_params + if drop_params is not None and isinstance(drop_params, bool) + else False + ), + ) elif custom_llm_provider == "azure": if litellm.AzureOpenAIO1Config().is_o_series_model(model=model): optional_params = litellm.AzureOpenAIO1Config().map_openai_params( @@ -4423,6 +4435,9 @@ def get_api_key(llm_provider: str, dynamic_api_key: Optional[str]): # nebius elif llm_provider == "nebius": api_key = api_key or litellm.nebius_key or get_secret("NEBIUS_API_KEY") + # wandb + elif llm_provider == "wandb": + api_key = api_key or litellm.wandb_key or get_secret("WANDB_API_KEY") return api_key @@ -5503,6 +5518,11 @@ def validate_environment( # noqa: PLR0915 keys_in_environment = True else: missing_keys.append("NEBIUS_API_KEY") + elif custom_llm_provider == "wandb": + if "WANDB_API_KEY" in os.environ: + keys_in_environment = True + else: + missing_keys.append("WANDB_API_KEY") elif custom_llm_provider == "dashscope": if "DASHSCOPE_API_KEY" in os.environ: keys_in_environment = True @@ -5617,6 +5637,11 @@ def validate_environment( # noqa: PLR0915 keys_in_environment = True else: missing_keys.append("NEBIUS_API_KEY") + elif model in litellm.wandb_models: + if "WANDB_API_KEY" in os.environ: + keys_in_environment = True + else: + missing_keys.append("WANDB_API_KEY") def filter_missing_keys(keys: List[str], exclude_pattern: str) -> List[str]: """Filter out keys that contain the exclude_pattern (case insensitive).""" @@ -7019,6 +7044,8 @@ class ProviderConfigManager: return litellm.NovitaConfig() elif litellm.LlmProviders.NEBIUS == provider: return litellm.NebiusConfig() + elif litellm.LlmProviders.WANDB == provider: + return litellm.WandbConfig() elif litellm.LlmProviders.DASHSCOPE == provider: return litellm.DashScopeChatConfig() elif litellm.LlmProviders.MOONSHOT == provider: diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index ff7b6b36dc8..8cbcd0be0a4 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -20501,5 +20501,131 @@ "metadata": { "notes": "Volcengine Doubao embedding model - text-240715 version with 2560 dimensions" } + }, + "wandb/openai/gpt-oss-120b": { + "max_tokens": 131072, + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "input_cost_per_token": 0.015, + "output_cost_per_token": 0.06, + "litellm_provider": "wandb", + "mode": "chat" + }, + "wandb/openai/gpt-oss-20b": { + "max_tokens": 131072, + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "input_cost_per_token": 0.005, + "output_cost_per_token": 0.02, + "litellm_provider": "wandb", + "mode": "chat" + }, + "wandb/zai-org/GLM-4.5": { + "max_tokens": 131072, + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "input_cost_per_token": 0.055, + "output_cost_per_token": 0.2, + "litellm_provider": "wandb", + "mode": "chat" + }, + "wandb/Qwen/Qwen3-235B-A22B-Instruct-2507": { + "max_tokens": 262144, + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "input_cost_per_token": 0.01, + "output_cost_per_token": 0.01, + "litellm_provider": "wandb", + "mode": "chat" + }, + "wandb/Qwen/Qwen3-Coder-480B-A35B-Instruct": { + "max_tokens": 262144, + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "input_cost_per_token": 0.1, + "output_cost_per_token": 0.15, + "litellm_provider": "wandb", + "mode": "chat" + }, + "wandb/Qwen/Qwen3-235B-A22B-Thinking-2507": { + "max_tokens": 262144, + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "input_cost_per_token": 0.01, + "output_cost_per_token": 0.01, + "litellm_provider": "wandb", + "mode": "chat" + }, + "wandb/moonshotai/Kimi-K2-Instruct": { + "max_tokens": 128000, + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "input_cost_per_token": 0.135, + "output_cost_per_token": 0.4, + "litellm_provider": "wandb", + "mode": "chat" + }, + "wandb/meta-llama/Llama-3.1-8B-Instruct": { + "max_tokens": 128000, + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "input_cost_per_token": 0.022, + "output_cost_per_token": 0.022, + "litellm_provider": "wandb", + "mode": "chat" + }, + "wandb/deepseek-ai/DeepSeek-V3.1": { + "max_tokens": 128000, + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "input_cost_per_token": 0.055, + "output_cost_per_token": 0.165, + "litellm_provider": "wandb", + "mode": "chat" + }, + "wandb/deepseek-ai/DeepSeek-R1-0528": { + "max_tokens": 161000, + "max_input_tokens": 161000, + "max_output_tokens": 161000, + "input_cost_per_token": 0.135, + "output_cost_per_token": 0.54, + "litellm_provider": "wandb", + "mode": "chat" + }, + "wandb/deepseek-ai/DeepSeek-V3-0324": { + "max_tokens": 161000, + "max_input_tokens": 161000, + "max_output_tokens": 161000, + "input_cost_per_token": 0.114, + "output_cost_per_token": 0.275, + "litellm_provider": "wandb", + "mode": "chat" + }, + "wandb/meta-llama/Llama-3.3-70B-Instruct": { + "max_tokens": 128000, + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "input_cost_per_token": 0.071, + "output_cost_per_token": 0.071, + "litellm_provider": "wandb", + "mode": "chat" + }, + "wandb/meta-llama/Llama-4-Scout-17B-16E-Instruct": { + "max_tokens": 64000, + "max_input_tokens": 64000, + "max_output_tokens": 64000, + "input_cost_per_token": 0.017, + "output_cost_per_token": 0.066, + "litellm_provider": "wandb", + "mode": "chat" + }, + "wandb/microsoft/Phi-4-mini-instruct": { + "max_tokens": 128000, + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "input_cost_per_token": 0.008, + "output_cost_per_token": 0.035, + "litellm_provider": "wandb", + "mode": "chat" } } diff --git a/tests/test_litellm/llms/wandb/test_wandb_chat_transformation.py b/tests/test_litellm/llms/wandb/test_wandb_chat_transformation.py new file mode 100644 index 00000000000..ef7bb0e44f0 --- /dev/null +++ b/tests/test_litellm/llms/wandb/test_wandb_chat_transformation.py @@ -0,0 +1,146 @@ +""" +Unit tests for WandB Inference configuration. + +These tests validate the WandbInferenceConfig class which extends OpenAIGPTConfig. +Nebius AI Studio is an OpenAI-compatible provider with minor customizations. +""" + +import os +import sys + +sys.path.insert( + 0, os.path.abspath("../../../../..") +) # Adds the parent directory to the system path + +import pytest + +import litellm +from litellm import completion +from litellm.llms.wandb.chat.transformation import WandbConfig + + +class TestWandbConfig: + """Test class for WandB Inference functionality""" + + def test_default_api_base(self): + """Test that default API base is used when none is provided""" + config = WandbConfig() + headers = {} + api_key = "fake-wandb-key" + + # Call validate_environment without specifying api_base + result = config.validate_environment( + headers=headers, + model="wandb/openai/gpt-oss-20b", + messages=[{"role": "user", "content": "Hey"}], + optional_params={}, + litellm_params={}, + api_key=api_key, + api_base=None, # Not providing api_base + ) + + # Verify headers are still set correctly + assert result["Authorization"] == f"Bearer {api_key}" + assert result["Content-Type"] == "application/json" + + # We can't directly test the api_base value here since validate_environment + # only returns the headers, but we can verify it doesn't raise an exception + # which would happen if api_base handling was incorrect + + @pytest.mark.respx() + def test_wandb_completion_mock(self, respx_mock): + """ + Mock test for WandB Inference completion using the model format from docs. + This test mocks the actual HTTP request to test the integration properly. + """ + + litellm.disable_aiohttp_transport = ( + True # since this uses respx, we need to set use_aiohttp_transport to False + ) + + # Set up environment variables for the test + api_key = "fake-wandb-key" + api_base = "https://api.inference.wandb.ai/v1" + model = "wandb/openai/gpt-oss-20b" + model_name = "gpt-oss-20b" # The actual model name without provider prefix + + # Mock the HTTP request to the WandB Inference API + respx_mock.post(f"{api_base}/chat/completions").respond( + json={ + "id": "chatcmpl-123", + "object": "chat.completion", + "created": 1677652288, + "model": model_name, + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": '```python\nprint("Hey from LiteLLM!")\n```\n\nThis simple Python code prints a greeting message from LiteLLM.', + }, + "finish_reason": "stop", + } + ], + "usage": { + "prompt_tokens": 9, + "completion_tokens": 12, + "total_tokens": 21, + }, + }, + status_code=200, + ) + + # Make the actual API call through LiteLLM + response = completion( + model=model, + messages=[ + {"role": "user", "content": "write code for saying hey from LiteLLM"} + ], + api_key=api_key, + api_base=api_base, + ) + + # Verify response structure + assert response is not None + + # If response is a streaming wrapper, extract the first chunk for assertions + # This handles both streaming and non-streaming responses + # For streaming, response is typically an iterator yielding (event, data) tuples + if hasattr(response, "__iter__") and not hasattr(response, "choices"): + # Streaming response: get the first chunk + first_chunk = next(iter(response)) + # first_chunk is likely a tuple: (event, data) + # Try to extract the data part + if isinstance(first_chunk, tuple) and len(first_chunk) == 2: + data = first_chunk[1] + else: + data = first_chunk + + # The data object should have .choices[0] with .delta or .message + choices = getattr(data, "choices", None) + assert choices is not None + assert len(choices) > 0 + choice = choices[0] + # For streaming, content may be in .delta or .message + content = None + if hasattr(choice, "delta") and hasattr(choice.delta, "content"): + content = choice.delta.content + elif hasattr(choice, "message") and hasattr(choice.message, "content"): + content = choice.message.content + assert content is not None + assert "```python" in content + assert "Hey from LiteLLM" in content + else: + # Non-streaming response + choices = getattr(response, "choices", None) + assert choices is not None + assert len(choices) > 0 + choice = choices[0] + message = getattr(choice, "message", None) + assert message is not None + content = getattr(message, "content", None) + assert content is not None + + # Check for specific content in the response + assert "```python" in content + assert "Hey from LiteLLM" in content From 428f8dcb7af8977a12ff108f779bbb67d2ef3016 Mon Sep 17 00:00:00 2001 From: xprilion Date: Thu, 11 Sep 2025 00:19:31 +0530 Subject: [PATCH 02/44] tinyfix --- litellm/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/litellm/__init__.py b/litellm/__init__.py index 50249250755..fabc8e280fe 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -60,7 +60,7 @@ from litellm.constants import ( empower_models, together_ai_models, baseten_models, - wandb_models, + WANDB_MODELS, REPEATED_STREAMING_CHUNK_LIMIT, request_timeout, open_ai_embedding_models, @@ -522,7 +522,7 @@ cometapi_models: Set = set() oci_models: Set = set() vercel_ai_gateway_models: Set = set() volcengine_models: Set = set() -wandb_models: Set = set() +wandb_models: Set = set(WANDB_MODELS) def is_bedrock_pricing_only_model(key: str) -> bool: From 5fe21764c1383f6c9b267c2199708430f051105c Mon Sep 17 00:00:00 2001 From: xprilion Date: Thu, 11 Sep 2025 00:35:01 +0530 Subject: [PATCH 03/44] tinyfix --- litellm/constants.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/constants.py b/litellm/constants.py index 7f9e5190e79..94f073c9cb3 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -758,7 +758,7 @@ nebius_embedding_models: set = set( ] ) -wandb_models: set = set( +WANDB_MODELS: set = set( [ # openai models "openai/gpt-oss-120b", From 470e1b7d2db1cff2978804c9ffc76c6a97519bfb Mon Sep 17 00:00:00 2001 From: xprilion Date: Tue, 16 Sep 2025 16:47:51 +0530 Subject: [PATCH 04/44] tinyfix json error --- model_prices_and_context_window.json | 1 + 1 file changed, 1 insertion(+) diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index fd7eeb76c78..777b2245fb7 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -20920,6 +20920,7 @@ "output_cost_per_token": 0.035, "litellm_provider": "wandb", "mode": "chat" + }, "ovhcloud/Qwen2.5-VL-72B-Instruct": { "max_tokens": 32000, "max_input_tokens": 32000, From a8059f80f4e83f65b862476ef46ed0b3e0bf90b6 Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Fri, 19 Sep 2025 18:29:43 +0900 Subject: [PATCH 05/44] feat: remove server_name prefix from list_tools when only one server is present (#14504) --- .../mcp_server/mcp_server_manager.py | 19 ++- .../mcp_server/rest_endpoints.py | 1 + .../proxy/_experimental/mcp_server/server.py | 24 ++-- .../mcp_server/test_mcp_server.py | 120 ++++++++++++++++++ .../mcp_server/test_mcp_server_manager.py | 106 ++++++++++++++++ 5 files changed, 255 insertions(+), 15 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index d0eadb36ba3..a10e1499e00 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -419,6 +419,7 @@ class MCPServerManager: self, server: MCPServer, mcp_auth_header: Optional[str] = None, + add_prefix: bool = True, ) -> List[MCPTool]: """ Helper method to get tools from a single MCP server with prefixed names. @@ -443,9 +444,11 @@ class MCPServerManager: tools = await self._fetch_tools_with_timeout(client, server.name) - prefixed_tools = self._create_prefixed_tools(tools, server) + prefixed_or_original_tools = self._create_prefixed_tools( + tools, server, add_prefix=add_prefix + ) - return prefixed_tools + return prefixed_or_original_tools except Exception as e: verbose_logger.warning( @@ -514,7 +517,7 @@ class MCPServerManager: return [] def _create_prefixed_tools( - self, tools: List[MCPTool], server: MCPServer + self, tools: List[MCPTool], server: MCPServer, add_prefix: bool = True ) -> List[MCPTool]: """ Create prefixed tools and update tool mapping. @@ -532,14 +535,16 @@ class MCPServerManager: for tool in tools: prefixed_name = add_server_prefix_to_tool_name(tool.name, prefix) - prefixed_tool = MCPTool( - name=prefixed_name, + name_to_use = prefixed_name if add_prefix else tool.name + + tool_obj = MCPTool( + name=name_to_use, description=tool.description, inputSchema=tool.inputSchema, ) - prefixed_tools.append(prefixed_tool) + prefixed_tools.append(tool_obj) - # Update tool to server mapping with both original and prefixed names + # Update tool to server mapping for resolution (support both forms) self.tool_name_to_mcp_server_name_mapping[tool.name] = prefix self.tool_name_to_mcp_server_name_mapping[prefixed_name] = prefix diff --git a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py index 2a9174717d1..399b79b4f7c 100644 --- a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py @@ -73,6 +73,7 @@ if MCP_AVAILABLE: tools = await global_mcp_server_manager._get_tools_from_server( server=server, mcp_auth_header=server_auth_header, + add_prefix=False, ) return _create_tool_response_objects(tools, server.mcp_info) diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index 3e45fccdc28..daba0c172f0 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -384,6 +384,9 @@ if MCP_AVAILABLE: allowed_mcp_servers=allowed_mcp_servers, ) + # Decide whether to add prefix based on number of allowed servers + add_prefix = not (len(allowed_mcp_servers) == 1) + # Get tools from each allowed server all_tools = [] for server_id in allowed_mcp_servers: @@ -406,6 +409,7 @@ if MCP_AVAILABLE: tools = await global_mcp_server_manager._get_tools_from_server( server=server, mcp_auth_header=server_auth_header, + add_prefix=add_prefix, ) all_tools.extend(tools) verbose_logger.debug( @@ -639,29 +643,33 @@ if MCP_AVAILABLE: if mcp_path_match: mcp_servers_str = mcp_path_match.group(1) optional_path = mcp_path_match.group(2) - + if mcp_servers_str: # First, try to split by comma for comma-separated lists - if ',' in mcp_servers_str: + if "," in mcp_servers_str: # For comma-separated lists, we need to handle the case where the last item # might include the path (e.g., "zapier,group1/tools" -> ["zapier", "group1/tools"]) parts = [s.strip() for s in mcp_servers_str.split(",") if s.strip()] - + # If there's an optional path AND the last part contains a slash that matches the optional path, # remove the path portion from the last server name - if optional_path and len(parts) > 0 and '/' in parts[-1]: + if optional_path and len(parts) > 0 and "/" in parts[-1]: last_part = parts[-1] # Check if the last part ends with the optional path - if optional_path and last_part.endswith(optional_path.lstrip('/')): + if optional_path and last_part.endswith( + optional_path.lstrip("/") + ): # Remove the path portion from the last server name - parts[-1] = last_part[:-len(optional_path.lstrip('/'))] - + parts[-1] = last_part[: -len(optional_path.lstrip("/"))] + mcp_servers_from_path = parts else: # For single server, it might be just a name or contain slashes # We need to determine where the server name ends and the path begins # This is tricky - let's use the original logic but handle comma cases differently - single_server_match = re.match(r"^([^/]+(?:/[^/]+)?)(?:/.*)?$", mcp_servers_str) + single_server_match = re.match( + r"^([^/]+(?:/[^/]+)?)(?:/.*)?$", mcp_servers_str + ) if single_server_match: server_name = single_server_match.group(1) mcp_servers_from_path = [server_name] diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py index 9d8f3b0cf6b..fdd7bc010f8 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py @@ -448,3 +448,123 @@ async def test_mcp_routing_with_conflicting_alias_and_group_name(): assert ( called_servers[0].server_id == specific_server.server_id ), "Should have contacted the specific server alias, not the group." + + +@pytest.mark.asyncio +async def test_list_tools_single_server_unprefixed_names(): + """When only one MCP server is allowed, list tools should return unprefixed names.""" + try: + from litellm.proxy._experimental.mcp_server.server import ( + _get_tools_from_mcp_servers, + set_auth_context, + ) + except ImportError: + pytest.skip("MCP server not available") + + # Mock user auth + user_api_key_auth = UserAPIKeyAuth(api_key="test_key", user_id="test_user") + set_auth_context(user_api_key_auth) + + # One allowed server + server = MagicMock() + server.server_id = "server1" + server.name = "Zapier MCP" + server.alias = "zapier" + + # Mock manager: allow just one server and return a tool based on add_prefix flag + mock_manager = MagicMock() + mock_manager.get_allowed_mcp_servers = AsyncMock(return_value=["server1"]) + mock_manager.get_mcp_server_by_id = ( + lambda server_id: server if server_id == "server1" else None + ) + + async def mock_get_tools_from_server( + server, mcp_auth_header=None, mcp_protocol_version=None, add_prefix=True + ): + tool = MagicMock() + tool.name = f"{server.alias}-toolA" if add_prefix else "toolA" + tool.description = "desc" + tool.inputSchema = {} + return [tool] + + mock_manager._get_tools_from_server = mock_get_tools_from_server + + with patch( + "litellm.proxy._experimental.mcp_server.server.global_mcp_server_manager", + mock_manager, + ): + tools = await _get_tools_from_mcp_servers( + user_api_key_auth=user_api_key_auth, + mcp_auth_header=None, + mcp_servers=None, + mcp_server_auth_headers=None, + mcp_protocol_version=None, + ) + + # Should be unprefixed since only one server is allowed + assert len(tools) == 1 + assert tools[0].name == "toolA" + + +@pytest.mark.asyncio +async def test_list_tools_multiple_servers_prefixed_names(): + """When multiple MCP servers are allowed, list tools should return prefixed names.""" + try: + from litellm.proxy._experimental.mcp_server.server import ( + _get_tools_from_mcp_servers, + set_auth_context, + ) + except ImportError: + pytest.skip("MCP server not available") + + # Mock user auth + user_api_key_auth = UserAPIKeyAuth(api_key="test_key", user_id="test_user") + set_auth_context(user_api_key_auth) + + # Two allowed servers + server1 = MagicMock() + server1.server_id = "server1" + server1.name = "Zapier MCP" + server1.alias = "zapier" + + server2 = MagicMock() + server2.server_id = "server2" + server2.name = "Jira MCP" + server2.alias = "jira" + + # Mock manager + mock_manager = MagicMock() + mock_manager.get_allowed_mcp_servers = AsyncMock( + return_value=["server1", "server2"] + ) + mock_manager.get_mcp_server_by_id = ( + lambda server_id: server1 if server_id == "server1" else server2 + ) + + async def mock_get_tools_from_server( + server, mcp_auth_header=None, mcp_protocol_version=None, add_prefix=True + ): + tool = MagicMock() + # When multiple servers, add_prefix should be True -> prefixed names + tool.name = f"{server.alias}-toolA" if add_prefix else "toolA" + tool.description = "desc" + tool.inputSchema = {} + return [tool] + + mock_manager._get_tools_from_server = mock_get_tools_from_server + + with patch( + "litellm.proxy._experimental.mcp_server.server.global_mcp_server_manager", + mock_manager, + ): + tools = await _get_tools_from_mcp_servers( + user_api_key_auth=user_api_key_auth, + mcp_auth_header=None, + mcp_servers=None, + mcp_server_auth_headers=None, + mcp_protocol_version=None, + ) + + # Should be prefixed since multiple servers are allowed + names = sorted([t.name for t in tools]) + assert names == ["jira-toolA", "zapier-toolA"] diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py index 3237de37636..e3bb085d5f4 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py @@ -420,6 +420,112 @@ class TestMCPServerManager: assert result["status"] == "healthy" assert result["tools_count"] == 1 + @pytest.mark.asyncio + async def test_get_tools_from_server_add_prefix(self): + """Verify _get_tools_from_server respects add_prefix True/False.""" + manager = MCPServerManager() + + # Create a minimal server with alias used as prefix + server = MCPServer( + server_id="zapier", + name="zapier", + transport=MCPTransport.http, + ) + + # Mock client creation and fetching tools + manager._create_mcp_client = MagicMock(return_value=object()) + + # Tools returned upstream (unprefixed from provider) + upstream_tool = MagicMock() + upstream_tool.name = "send_email" + upstream_tool.description = "Send an email" + upstream_tool.inputSchema = {} + + manager._fetch_tools_with_timeout = AsyncMock(return_value=[upstream_tool]) + + # Case 1: add_prefix=True (default for multi-server) -> expect prefixed + tools_prefixed = await manager._get_tools_from_server(server, add_prefix=True) + assert len(tools_prefixed) == 1 + assert tools_prefixed[0].name == "zapier-send_email" + + # Case 2: add_prefix=False (single-server) -> expect unprefixed + tools_unprefixed = await manager._get_tools_from_server( + server, add_prefix=False + ) + assert len(tools_unprefixed) == 1 + assert tools_unprefixed[0].name == "send_email" + + def test_create_prefixed_tools_updates_mapping_for_both_forms(self): + """_create_prefixed_tools should populate mapping for prefixed and original names even when not adding prefix in output.""" + manager = MCPServerManager() + + server = MCPServer( + server_id="jira", + name="jira", + transport=MCPTransport.http, + ) + + # Input tools as would come from upstream + t1 = MagicMock() + t1.name = "create_issue" + t1.description = "" + t1.inputSchema = {} + t2 = MagicMock() + t2.name = "close_issue" + t2.description = "" + t2.inputSchema = {} + + # Do not add prefix in returned objects + out_tools = manager._create_prefixed_tools([t1, t2], server, add_prefix=False) + + # Returned names should be unprefixed + names = sorted([t.name for t in out_tools]) + assert names == ["close_issue", "create_issue"] + + # Mapping should include both original and prefixed names -> resolves calls either way + assert manager.tool_name_to_mcp_server_name_mapping["create_issue"] == "jira" + assert ( + manager.tool_name_to_mcp_server_name_mapping["jira-create_issue"] == "jira" + ) + assert manager.tool_name_to_mcp_server_name_mapping["close_issue"] == "jira" + assert ( + manager.tool_name_to_mcp_server_name_mapping["jira-close_issue"] == "jira" + ) + + def test_get_mcp_server_from_tool_name_with_prefixed_and_unprefixed(self): + """After mapping is populated, manager resolves both prefixed and unprefixed tool names to the same server.""" + manager = MCPServerManager() + + server = MCPServer( + server_id="zapier", + name="zapier", + server_name="zapier", + transport=MCPTransport.http, + ) + + # Register server so resolution can find it + manager.registry = {server.server_id: server} + + # Populate mapping (add_prefix value doesn't matter for mapping population) + base_tool = MagicMock() + base_tool.name = "create_zap" + base_tool.description = "" + base_tool.inputSchema = {} + _ = manager._create_prefixed_tools([base_tool], server, add_prefix=False) + + # Unprefixed resolution + resolved_server_unpref = manager._get_mcp_server_from_tool_name("create_zap") + print(resolved_server_unpref) + assert resolved_server_unpref is not None + assert resolved_server_unpref.server_id == server.server_id + + # Prefixed resolution + resolved_server_pref = manager._get_mcp_server_from_tool_name( + "zapier-create_zap" + ) + assert resolved_server_pref is not None + assert resolved_server_pref.server_id == server.server_id + if __name__ == "__main__": pytest.main([__file__]) From 7d93856c9344964ba2ba400333bdf354cc022443 Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Sat, 20 Sep 2025 06:44:06 +0900 Subject: [PATCH 06/44] fix: correct mistake introduced during conflict resolution --- .../proxy/_experimental/mcp_server/test_mcp_server.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py index fdd7bc010f8..def4666c43f 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py @@ -102,7 +102,7 @@ async def test_get_tools_from_mcp_servers_continues_when_one_server_fails(): working_server if server_id == "working_server" else failing_server ) - async def mock_get_tools_from_server(server, mcp_auth_header=None): + async def mock_get_tools_from_server(server, mcp_auth_header=None, add_prefix=True): if server.name == "working_server": # Working server returns tools tool1 = MagicMock() @@ -184,7 +184,7 @@ async def test_get_tools_from_mcp_servers_handles_all_servers_failing(): failing_server1 if server_id == "failing_server1" else failing_server2 ) - async def mock_get_tools_from_server(server, mcp_auth_header=None): + async def mock_get_tools_from_server(server, mcp_auth_header=None, add_prefix=True): # All servers fail raise Exception(f"Server {server.name} connection failed") @@ -479,7 +479,7 @@ async def test_list_tools_single_server_unprefixed_names(): ) async def mock_get_tools_from_server( - server, mcp_auth_header=None, mcp_protocol_version=None, add_prefix=True + server, mcp_auth_header=None, add_prefix=False ): tool = MagicMock() tool.name = f"{server.alias}-toolA" if add_prefix else "toolA" @@ -498,7 +498,6 @@ async def test_list_tools_single_server_unprefixed_names(): mcp_auth_header=None, mcp_servers=None, mcp_server_auth_headers=None, - mcp_protocol_version=None, ) # Should be unprefixed since only one server is allowed @@ -542,7 +541,7 @@ async def test_list_tools_multiple_servers_prefixed_names(): ) async def mock_get_tools_from_server( - server, mcp_auth_header=None, mcp_protocol_version=None, add_prefix=True + server, mcp_auth_header=None, add_prefix=True ): tool = MagicMock() # When multiple servers, add_prefix should be True -> prefixed names @@ -562,7 +561,6 @@ async def test_list_tools_multiple_servers_prefixed_names(): mcp_auth_header=None, mcp_servers=None, mcp_server_auth_headers=None, - mcp_protocol_version=None, ) # Should be prefixed since multiple servers are allowed From bf0637821b7ae56b05acc2dae8742d4fb98a891c Mon Sep 17 00:00:00 2001 From: eycjur Date: Sun, 21 Sep 2025 10:29:03 +0000 Subject: [PATCH 07/44] support flux image edit with azure ai --- litellm/llms/azure_ai/common_utils.py | 2 +- litellm/llms/azure_ai/image_edit/__init__.py | 15 +++ .../azure_ai/image_edit/transformation.py | 103 ++++++++++++++++++ litellm/utils.py | 6 + 4 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 litellm/llms/azure_ai/image_edit/__init__.py create mode 100644 litellm/llms/azure_ai/image_edit/transformation.py diff --git a/litellm/llms/azure_ai/common_utils.py b/litellm/llms/azure_ai/common_utils.py index dcc9335e42d..64c424d23c6 100644 --- a/litellm/llms/azure_ai/common_utils.py +++ b/litellm/llms/azure_ai/common_utils.py @@ -29,7 +29,7 @@ class AzureFoundryModelInfo(BaseLLMModelInfo): api_version = ( api_version or litellm.api_version - or get_secret_str("AZURE_API_VERSION") + or get_secret_str("AZURE_AI_API_VERSION") ) return api_version diff --git a/litellm/llms/azure_ai/image_edit/__init__.py b/litellm/llms/azure_ai/image_edit/__init__.py new file mode 100644 index 00000000000..e0e57bec403 --- /dev/null +++ b/litellm/llms/azure_ai/image_edit/__init__.py @@ -0,0 +1,15 @@ +from litellm.llms.base_llm.image_edit.transformation import BaseImageEditConfig + +from .transformation import AzureFoundryFluxImageEditConfig + +__all__ = ["AzureFoundryFluxImageEditConfig"] + + +def get_azure_ai_image_edit_config(model: str) -> BaseImageEditConfig: + model = model.lower() + model = model.replace("-", "") + model = model.replace("_", "") + if model == "" or "flux" in model: # empty model is flux + return AzureFoundryFluxImageEditConfig() + else: + raise ValueError(f"Model {model} is not supported for Azure AI image editing.") diff --git a/litellm/llms/azure_ai/image_edit/transformation.py b/litellm/llms/azure_ai/image_edit/transformation.py new file mode 100644 index 00000000000..e0be3f3d29a --- /dev/null +++ b/litellm/llms/azure_ai/image_edit/transformation.py @@ -0,0 +1,103 @@ +from typing import Optional + +import httpx + +import litellm +from litellm.llms.azure_ai.common_utils import AzureFoundryModelInfo +from litellm.llms.openai.image_edit.transformation import OpenAIImageEditConfig +from litellm.secret_managers.main import get_secret_str +from litellm.utils import _add_path_to_api_base + + +class AzureFoundryFluxImageEditConfig(OpenAIImageEditConfig): + """ + Azure AI Foundry FLUX image edit config + + Supports FLUX models including FLUX-1.1-pro and FLUX-1-kontext-pro for image editing. + + Azure AI Foundry FLUX models handle image editing through the /images/edits endpoint, + same as standard Azure OpenAI models. The request format uses multipart/form-data + with image files and prompt. + """ + + def validate_environment( + self, + headers: dict, + model: str, + api_key: Optional[str] = None, + ) -> dict: + """ + Validate Azure AI Foundry environment and set up authentication + Uses Api-Key header format like Azure OpenAI + + Azure AI Foundry uses the same authentication format as Azure OpenAI: + - Header: Api-Key (not Authorization Bearer) + - Endpoint: /openai/deployments/{model}/images/edits (for image editing) + """ + api_key = AzureFoundryModelInfo.get_api_key(api_key) + + if not api_key: + raise ValueError( + f"Azure AI API key is required for model {model}. Set AZURE_AI_API_KEY environment variable or pass api_key parameter." + ) + + headers.update( + { + "Api-Key": api_key, # Azure AI Foundry uses Api-Key header format + } + ) + return headers + + def get_complete_url( + self, + model: str, + api_base: Optional[str], + litellm_params: dict, + ) -> str: + """ + Constructs a complete URL for Azure AI Foundry image edits API request. + + Azure AI Foundry FLUX models handle image editing through the /images/edits + endpoint. + + Args: + - model: Model name (deployment name for Azure AI Foundry) + - api_base: Base URL for Azure AI endpoint + - litellm_params: Additional parameters including api_version + + Returns: + - Complete URL for the image edits endpoint + """ + api_base = AzureFoundryModelInfo.get_api_base(api_base) + + if api_base is None: + raise ValueError( + "Azure AI API base is required. Set AZURE_AI_API_BASE environment variable or pass api_base parameter." + ) + + api_version = (litellm_params.get("api_version") or litellm.api_version + or get_secret_str("AZURE_AI_API_VERSION") + ) + if api_version is None: + # API version is mandatory for Azure AI Foundry + raise ValueError( + "Azure API version is required. Set AZURE_AI_API_VERSION environment variable or pass api_version parameter." + ) + + # Add the path to the base URL using the model as deployment name + # Azure AI Foundry FLUX models use /images/edits for editing + if "/openai/deployments/" in api_base: + new_url = _add_path_to_api_base( + api_base=api_base, + ending_path="/images/edits", + ) + else: + new_url = _add_path_to_api_base( + api_base=api_base, + ending_path=f"/openai/deployments/{model}/images/edits", + ) + + # Use the new query_params dictionary + final_url = httpx.URL(new_url).copy_with(params={"api-version": api_version}) + + return str(final_url) diff --git a/litellm/utils.py b/litellm/utils.py index e4cbaec7065..49c8b178fca 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -7501,6 +7501,12 @@ class ProviderConfigManager: ) return RecraftImageEditConfig() + elif LlmProviders.AZURE_AI == provider: + from litellm.llms.azure_ai.image_edit import ( + get_azure_ai_image_edit_config, + ) + + return get_azure_ai_image_edit_config(model) elif LlmProviders.LITELLM_PROXY == provider: from litellm.llms.litellm_proxy.image_edit.transformation import ( LiteLLMProxyImageEditConfig, From 27a755eb84b05df04d1e1e4d4fec155ed1ee8ff0 Mon Sep 17 00:00:00 2001 From: berri-teddy Date: Sun, 21 Sep 2025 15:08:44 -0700 Subject: [PATCH 08/44] Update docs to explainb bridging between endpoints and mode --- docs/my-website/docs/completion/usage.md | 1 + docs/my-website/docs/response_api.md | 3 +++ 2 files changed, 4 insertions(+) diff --git a/docs/my-website/docs/completion/usage.md b/docs/my-website/docs/completion/usage.md index 2a9eab941ea..c388e5bfee1 100644 --- a/docs/my-website/docs/completion/usage.md +++ b/docs/my-website/docs/completion/usage.md @@ -26,6 +26,7 @@ response = completion( print(response.usage) ``` +> **Note:** LiteLLM supports endpoint bridging—if a model does not natively support a requested endpoint, LiteLLM will automatically route the call to the correct supported endpoint (such as bridging `/chat/completions` to `/responses` or vice versa) based on the model's `mode`set in `model_prices_and_context_window`. ## Streaming Usage diff --git a/docs/my-website/docs/response_api.md b/docs/my-website/docs/response_api.md index 94d7c73be05..c1aa4861735 100644 --- a/docs/my-website/docs/response_api.md +++ b/docs/my-website/docs/response_api.md @@ -3,8 +3,11 @@ import TabItem from '@theme/TabItem'; # /responses [Beta] + LiteLLM provides a BETA endpoint in the spec of [OpenAI's `/responses` API](https://platform.openai.com/docs/api-reference/responses) +Requests to /chat/completions may be bridged here automatically when the provider lacks support for that endpoint. The model’s default `mode` determines how bridging works.(see `model_prices_and_context_window`) + | Feature | Supported | Notes | |---------|-----------|--------| | Cost Tracking | ✅ | Works with all supported models | From 88e08c98fb7df2e476f76e357408961f851d1460 Mon Sep 17 00:00:00 2001 From: berri-teddy Date: Sun, 21 Sep 2025 15:21:49 -0700 Subject: [PATCH 09/44] Clarify IAM AssumeRole Policy requirement --- docs/my-website/docs/providers/bedrock.md | 33 +++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/docs/my-website/docs/providers/bedrock.md b/docs/my-website/docs/providers/bedrock.md index 13f017333d3..b7ea8025f7e 100644 --- a/docs/my-website/docs/providers/bedrock.md +++ b/docs/my-website/docs/providers/bedrock.md @@ -2281,6 +2281,39 @@ response = completion( Make the bedrock completion call +--- + +### Required AWS IAM Policy for AssumeRole + +To use `aws_role_name` (STS AssumeRole) with LiteLLM, your IAM user or role **must** have permission to call `sts:AssumeRole` on the target role. If you see an error like: + +``` +An error occurred (AccessDenied) when calling the AssumeRole operation: User: arn:aws:sts::...:assumed-role/litellm-ecs-task-role/... is not authorized to perform: sts:AssumeRole on resource: arn:aws:iam::...:role/Enterprise/BedrockCrossAccountConsumer +``` + +This means the IAM identity running LiteLLM does **not** have permission to assume the target role. You must update your IAM policy to allow this action. + +#### Example IAM Policy + +Replace `` with the ARN of the role you want to assume (e.g., `arn:aws:iam::123456789012:role/Enterprise/BedrockCrossAccountConsumer`). + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "sts:AssumeRole", + "Resource": "" + } + ] +} +``` + +**Note:** The target role itself must also trust the calling IAM identity (via its trust policy) for AssumeRole to succeed. See [AWS AssumeRole docs](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_use_switch-role-api.html) for more details. + +--- + From 23716c73d24120dbedd290a374cd71132a256ea7 Mon Sep 17 00:00:00 2001 From: berri-teddy Date: Sun, 21 Sep 2025 15:52:14 -0700 Subject: [PATCH 10/44] Add max_connections cache_params --- docs/my-website/docs/proxy/caching.md | 14 ++++++++++ docs/my-website/docs/proxy/config_settings.md | 1 + docs/my-website/docs/proxy/db_deadlocks.md | 26 +++++++++++++++++++ docs/my-website/docs/proxy/load_balancing.md | 3 +++ 4 files changed, 44 insertions(+) diff --git a/docs/my-website/docs/proxy/caching.md b/docs/my-website/docs/proxy/caching.md index 1fb7385f689..617609cf08a 100644 --- a/docs/my-website/docs/proxy/caching.md +++ b/docs/my-website/docs/proxy/caching.md @@ -958,6 +958,19 @@ curl http://localhost:4000/v1/chat/completions \ + +## Redis max_connections + +You can set the `max_connections` parameter in your `cache_params` for Redis. This is passed directly to the Redis client and controls the maximum number of simultaneous connections in the pool. If you see errors like `No connection available`, try increasing this value: + +```yaml +litellm_settings: + cache: true + cache_params: + type: redis + max_connections: 100 +``` + ## Supported `cache_params` on proxy config.yaml ```yaml @@ -966,6 +979,7 @@ cache_params: ttl: Optional[float] default_in_memory_ttl: Optional[float] default_in_redis_ttl: Optional[float] + max_connections: Optional[Int] # Type of cache (options: "local", "redis", "s3") type: s3 diff --git a/docs/my-website/docs/proxy/config_settings.md b/docs/my-website/docs/proxy/config_settings.md index 974e95a07bd..4a16d3fee4f 100644 --- a/docs/my-website/docs/proxy/config_settings.md +++ b/docs/my-website/docs/proxy/config_settings.md @@ -50,6 +50,7 @@ litellm_settings: port: 6379 # The port number for the Redis cache. Required if type is "redis". password: "your_password" # The password for the Redis cache. Required if type is "redis". namespace: "litellm.caching.caching" # namespace for redis cache + max_connections: 100 # [OPTIONAL] Maximum number of Redis connections. Passed directly to redis-py. Increase if you see 'No connection available' errors under high load. # Optional - Redis Cluster Settings redis_startup_nodes: [{"host": "127.0.0.1", "port": "7001"}] diff --git a/docs/my-website/docs/proxy/db_deadlocks.md b/docs/my-website/docs/proxy/db_deadlocks.md index 0eee928fa64..ef9d31d6232 100644 --- a/docs/my-website/docs/proxy/db_deadlocks.md +++ b/docs/my-website/docs/proxy/db_deadlocks.md @@ -84,3 +84,29 @@ LiteLLM emits the following prometheus metrics to monitor the health/status of t | `litellm_in_memory_spend_update_queue_size` | In-memory aggregate spend values for keys, users, teams, team members, etc.| In-Memory | | `litellm_redis_spend_update_queue_size` | Redis aggregate spend values for keys, users, teams, etc. | Redis | + +## Troubleshooting: Redis Connection Errors + +You may see errors like: + +``` +LiteLLM Redis Caching: async async_increment() - Got exception from REDIS No connection available., Writing value=21 +LiteLLM Redis Caching: async set_cache_pipeline() - Got exception from REDIS No connection available., Writing value=None +``` + +This means all available Redis connections are in use, and LiteLLM cannot obtain a new connection from the pool. This can happen under high load or with many concurrent proxy requests. + +**Solution:** + +- Increase the `max_connections` parameter in your Redis config section in `proxy_config.yaml` to allow more simultaneous connections. For example: + +```yaml +litellm_settings: + cache: True + cache_params: + type: redis + max_connections: 100 # Increase as needed for your traffic +``` + +Adjust this value based on your expected concurrency and Redis server capacity. + diff --git a/docs/my-website/docs/proxy/load_balancing.md b/docs/my-website/docs/proxy/load_balancing.md index bcbc4e93651..54c917bbbca 100644 --- a/docs/my-website/docs/proxy/load_balancing.md +++ b/docs/my-website/docs/proxy/load_balancing.md @@ -172,6 +172,9 @@ router_settings: redis_host: redis_password: redis_port: 1992 + cache_params: + type: redis + max_connections: 100 # maximum Redis connections in the pool; tune based on expected concurrency/load ``` ## Router settings on config - routing_strategy, model_group_alias From cdb7c99ab6d0aa282171d9f8ffd9c27fd003ffaa Mon Sep 17 00:00:00 2001 From: berri-teddy Date: Sun, 21 Sep 2025 16:03:53 -0700 Subject: [PATCH 11/44] Max_connections in config_settings update --- docs/my-website/docs/proxy/config_settings.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/my-website/docs/proxy/config_settings.md b/docs/my-website/docs/proxy/config_settings.md index 4a16d3fee4f..f70701886b5 100644 --- a/docs/my-website/docs/proxy/config_settings.md +++ b/docs/my-website/docs/proxy/config_settings.md @@ -50,7 +50,7 @@ litellm_settings: port: 6379 # The port number for the Redis cache. Required if type is "redis". password: "your_password" # The password for the Redis cache. Required if type is "redis". namespace: "litellm.caching.caching" # namespace for redis cache - max_connections: 100 # [OPTIONAL] Maximum number of Redis connections. Passed directly to redis-py. Increase if you see 'No connection available' errors under high load. + max_connections: 100 # [OPTIONAL] Set Maximum number of Redis connections. Passed directly to redis-py. # Optional - Redis Cluster Settings redis_startup_nodes: [{"host": "127.0.0.1", "port": "7001"}] From dc6c6fe4f585dfd71bddd9645d5b7c83e2ca7cf9 Mon Sep 17 00:00:00 2001 From: berri-teddy Date: Sun, 21 Sep 2025 16:47:10 -0700 Subject: [PATCH 12/44] Getting Started now has transform utils to show how requests are transformed in litellm --- docs/my-website/docs/index.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/my-website/docs/index.md b/docs/my-website/docs/index.md index 3f5e1b479c3..11d2963b7a3 100644 --- a/docs/my-website/docs/index.md +++ b/docs/my-website/docs/index.md @@ -524,6 +524,15 @@ try: except OpenAIError as e: print(e) ``` +### See How LiteLLM Transforms Your Requests + +Want to understand how LiteLLM parses and normalizes your LLM API requests? Use the `/utils/transform_request` endpoint to see exactly how your request is transformed internally. + +You can try it out now directly on our Demo App! +Go to the [LiteLLM API docs for transform_request](https://litellm-api.up.railway.app/#/llm%20utils/transform_request_utils_transform_request_post) + +LiteLLM will show you the normalized, provider-agnostic version of your request. This is useful for debugging, learning, and understanding how LiteLLM handles different providers and options. + ### Logging Observability - Log LLM Input/Output ([Docs](https://docs.litellm.ai/docs/observability/callbacks)) LiteLLM exposes pre defined callbacks to send data to Lunary, MLflow, Langfuse, Helicone, Promptlayer, Traceloop, Slack From 861ec4e18fcfacca53e9050264808a11e4497878 Mon Sep 17 00:00:00 2001 From: berri-teddy Date: Sun, 21 Sep 2025 17:17:33 -0700 Subject: [PATCH 13/44] Removed broken links to callback management guide as it didn't exist. Reformatted, and linked cookbooks --- .../docs/observability/callbacks.md | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/docs/my-website/docs/observability/callbacks.md b/docs/my-website/docs/observability/callbacks.md index 040d83697d3..b752bdc2764 100644 --- a/docs/my-website/docs/observability/callbacks.md +++ b/docs/my-website/docs/observability/callbacks.md @@ -5,13 +5,15 @@ liteLLM provides `input_callbacks`, `success_callbacks` and `failure_callbacks`, making it easy for you to send data to a particular provider depending on the status of your responses. :::tip -**New to LiteLLM Callbacks?** Check out our comprehensive [Callback Management Guide](./callback_management.md) to understand when to use different callback hooks like `async_log_success_event` vs `async_post_call_success_hook`. +**New to LiteLLM Callbacks?** + +- For proxy/server logging and observability, see the [Proxy Logging Guide](https://docs.litellm.ai/docs/proxy/logging). +- To write your own callback logic, see the [Custom Callbacks Guide](https://docs.litellm.ai/docs/observability/custom_callback). ::: -liteLLM supports: -- [Custom Callback Functions](https://docs.litellm.ai/docs/observability/custom_callback) -- [Callback Management Guide](./callback_management.md) - **Comprehensive guide for choosing the right hooks** +### Supported Callback Integrations + - [Lunary](https://lunary.ai/docs) - [Langfuse](https://langfuse.com/docs) - [LangSmith](https://www.langchain.com/langsmith) @@ -21,9 +23,20 @@ liteLLM supports: - [Sentry](https://docs.sentry.io/platforms/python/) - [PostHog](https://posthog.com/docs/libraries/python) - [Slack](https://slack.dev/bolt-python/concepts) +- [Arize](https://docs.arize.com/) +- [PromptLayer](https://docs.promptlayer.com/) This is **not** an extensive list. Please check the dropdown for all logging integrations. +### Related Cookbooks +Try out our cookbooks for code snippets and interactive demos: + +- [Langfuse Callback Example (Colab)](https://colab.research.google.com/github/BerriAI/litellm/blob/main/cookbook/logging_observability/LiteLLM_Langfuse.ipynb) +- [Lunary Callback Example (Colab)](https://colab.research.google.com/github/BerriAI/litellm/blob/main/cookbook/logging_observability/LiteLLM_Lunary.ipynb) +- [Arize Callback Example (Colab)](https://colab.research.google.com/github/BerriAI/litellm/blob/main/cookbook/logging_observability/LiteLLM_Arize.ipynb) +- [Proxy + Langfuse Callback Example (Colab)](https://colab.research.google.com/github/BerriAI/litellm/blob/main/cookbook/logging_observability/LiteLLM_Proxy_Langfuse.ipynb) +- [PromptLayer Callback Example (Colab)](https://colab.research.google.com/github/BerriAI/litellm/blob/main/cookbook/LiteLLM_PromptLayer.ipynb) + ### Quick Start ```python From 97ec8f5b0ee5a84357c25817a07034f0c6d1bed4 Mon Sep 17 00:00:00 2001 From: berri-teddy Date: Sun, 21 Sep 2025 17:42:24 -0700 Subject: [PATCH 14/44] Added models.litellm.ai to various files for full models list --- docs/my-website/docs/fine_tuning.md | 2 ++ docs/my-website/docs/getting_started.md | 3 ++- docs/my-website/docs/image_edits.md | 3 +++ docs/my-website/docs/image_generation.md | 2 ++ docs/my-website/docs/moderation.md | 2 ++ docs/my-website/docs/proxy_api.md | 2 +- docs/my-website/docs/rerank.md | 2 ++ 7 files changed, 14 insertions(+), 2 deletions(-) diff --git a/docs/my-website/docs/fine_tuning.md b/docs/my-website/docs/fine_tuning.md index f9a9297e062..f3f955cb01d 100644 --- a/docs/my-website/docs/fine_tuning.md +++ b/docs/my-website/docs/fine_tuning.md @@ -13,6 +13,8 @@ This is an Enterprise only endpoint [Get Started with Enterprise here](https://c | Feature | Supported | Notes | |-------|-------|-------| | Supported Providers | OpenAI, Azure OpenAI, Vertex AI | - | + +#### ⚡️See an exhaustive list of supported models and providers at [models.litellm.ai](https://models.litellm.ai/) | Cost Tracking | 🟡 | [Let us know if you need this](https://github.com/BerriAI/litellm/issues) | | Logging | ✅ | Works across all logging integrations | diff --git a/docs/my-website/docs/getting_started.md b/docs/my-website/docs/getting_started.md index 15ee00a7273..6b2c1fd531e 100644 --- a/docs/my-website/docs/getting_started.md +++ b/docs/my-website/docs/getting_started.md @@ -32,7 +32,8 @@ Next Steps 👉 [Call all supported models - e.g. Claude-2, Llama2-70b, etc.](./ More details 👉 - [Completion() function details](./completion/) -- [All supported models / providers on LiteLLM](./providers/) +- [Overview of supported models / providers on LiteLLM](./providers/) +- [Search all models / providers](https://models.litellm.ai/) - [Build your own OpenAI proxy](https://github.com/BerriAI/liteLLM-proxy/tree/main) ## streaming diff --git a/docs/my-website/docs/image_edits.md b/docs/my-website/docs/image_edits.md index 246e1c70f0e..84dddd5e4ad 100644 --- a/docs/my-website/docs/image_edits.md +++ b/docs/my-website/docs/image_edits.md @@ -18,6 +18,9 @@ LiteLLM provides image editing functionality that maps to OpenAI's `/images/edit | Supported LiteLLM Proxy Versions | 1.71.1+ | | | Supported LLM providers | **OpenAI** | Currently only `openai` is supported | + #### ⚡️See all supported models and providers at [models.litellm.ai](https://models.litellm.ai/) + + ## Usage ### LiteLLM Python SDK diff --git a/docs/my-website/docs/image_generation.md b/docs/my-website/docs/image_generation.md index 7e7ff9922d6..8cd5803aa6c 100644 --- a/docs/my-website/docs/image_generation.md +++ b/docs/my-website/docs/image_generation.md @@ -279,6 +279,8 @@ print(f"response: {response}") ## Supported Providers +#### ⚡️See all supported models and providers at [models.litellm.ai](https://models.litellm.ai/) + | Provider | Documentation Link | |----------|-------------------| | OpenAI | [OpenAI Image Generation →](./providers/openai) | diff --git a/docs/my-website/docs/moderation.md b/docs/my-website/docs/moderation.md index 95fe8b2856d..f9c2810bc8a 100644 --- a/docs/my-website/docs/moderation.md +++ b/docs/my-website/docs/moderation.md @@ -130,6 +130,8 @@ Here's the exact json output and type you can expect from all moderation calls: ## **Supported Providers** +#### ⚡️See all supported models and providers at [models.litellm.ai](https://models.litellm.ai/) + | Provider | |-------------| | OpenAI | diff --git a/docs/my-website/docs/proxy_api.md b/docs/my-website/docs/proxy_api.md index 89bfacbe19f..7612645fb54 100644 --- a/docs/my-website/docs/proxy_api.md +++ b/docs/my-website/docs/proxy_api.md @@ -27,7 +27,7 @@ Email us @ krrish@berri.ai ## Supported Models for LiteLLM Key These are the models that currently work with the "sk-litellm-.." keys. -For a complete list of models/providers that you can call with LiteLLM, [check out our provider list](./providers/) +For a complete list of models/providers that you can call with LiteLLM, [check out our provider list](./providers/) or check out [models.litellm.ai](https://models.litellm.ai/) * OpenAI models - [OpenAI docs](./providers/openai.md) * gpt-4 diff --git a/docs/my-website/docs/rerank.md b/docs/my-website/docs/rerank.md index c57eacbb224..cad64718384 100644 --- a/docs/my-website/docs/rerank.md +++ b/docs/my-website/docs/rerank.md @@ -109,6 +109,8 @@ curl http://0.0.0.0:4000/rerank \ ## **Supported Providers** +#### ⚡️See all supported models and providers at [models.litellm.ai](https://models.litellm.ai/) + | Provider | Link to Usage | |-------------|--------------------| | Cohere (v1 + v2 clients) | [Usage](#quick-start) | From 1c6667021a19e57782834e79fa7f6c0973176445 Mon Sep 17 00:00:00 2001 From: berri-teddy Date: Sun, 21 Sep 2025 17:47:28 -0700 Subject: [PATCH 15/44] Linked authentication for AWS in bedrock guardrails doc --- docs/my-website/docs/proxy/guardrails/bedrock.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/my-website/docs/proxy/guardrails/bedrock.md b/docs/my-website/docs/proxy/guardrails/bedrock.md index 6725acf1f25..4a1a0a246f8 100644 --- a/docs/my-website/docs/proxy/guardrails/bedrock.md +++ b/docs/my-website/docs/proxy/guardrails/bedrock.md @@ -4,6 +4,10 @@ import TabItem from '@theme/TabItem'; # Bedrock Guardrails +:::tip ⚡️ +If you haven't set up or authenticated your Bedrock provider yet, see the [Bedrock Provider Setup & Authentication Guide](../../providers/bedrock.md). +::: + LiteLLM supports Bedrock guardrails via the [Bedrock ApplyGuardrail API](https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_ApplyGuardrail.html). ## Quick Start From 164b0294e446d123a8256aade88ef2fa0e67d22e Mon Sep 17 00:00:00 2001 From: berri-teddy Date: Sun, 21 Sep 2025 18:02:40 -0700 Subject: [PATCH 16/44] Updated vertex with gemini api alternative config --- docs/my-website/docs/providers/vertex.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/docs/my-website/docs/providers/vertex.md b/docs/my-website/docs/providers/vertex.md index cb90b7434e7..2fbc5a01e1f 100644 --- a/docs/my-website/docs/providers/vertex.md +++ b/docs/my-website/docs/providers/vertex.md @@ -196,7 +196,19 @@ model_list: vertex_location: "us-central1" vertex_credentials: "/path/to/service_account.json" # [OPTIONAL] Do this OR `!gcloud auth application-default login` - run this to add vertex credentials to your env ``` - +or +```yaml +model_list: + - model_name: gemini-pro + litellm_params: + model: vertex_ai/gemini-1.5-pro + litellm_credential_name: vertex-global + vertex_project: project-name-here + vertex_location: global + base_model: gemini + model_info: + provider: Vertex +``` 2. Start Proxy ``` From 53201c78694e21a7e5e8c1903e38898c5d637365 Mon Sep 17 00:00:00 2001 From: berri-teddy Date: Sun, 21 Sep 2025 18:14:57 -0700 Subject: [PATCH 17/44] Add async_post_call_success_hook code snippet --- .../docs/observability/custom_callback.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/my-website/docs/observability/custom_callback.md b/docs/my-website/docs/observability/custom_callback.md index c206c23d0f4..cfe97ca42c0 100644 --- a/docs/my-website/docs/observability/custom_callback.md +++ b/docs/my-website/docs/observability/custom_callback.md @@ -67,6 +67,23 @@ asyncio.run(completion()) - `async_post_call_success_hook` - Access user data + modify responses - `async_pre_call_hook` - Modify requests before sending +### Example: Modifying the Response in async_post_call_success_hook + +You can use `async_post_call_success_hook` to add custom headers or metadata to the response before it is returned to the client. For example: + +```python +async def async_post_call_success_hook(data, user_api_key_dict, response): + # Add a custom header to the response + additional_headers = getattr(response, "_hidden_params", {}).get("additional_headers", {}) or {} + additional_headers["x-litellm-custom-header"] = "my-value" + if not hasattr(response, "_hidden_params"): + response._hidden_params = {} + response._hidden_params["additional_headers"] = additional_headers + return response +``` + +This allows you to inject custom metadata or headers into the response for downstream consumers. You can use this pattern to pass information to clients, proxies, or observability tools. + ## Callback Functions If you just want to log on a specific event (e.g. on input) - you can use callback functions. From 665bf81bafe9876cea2210d0dbb421155a396dd8 Mon Sep 17 00:00:00 2001 From: berri-teddy Date: Sun, 21 Sep 2025 18:25:35 -0700 Subject: [PATCH 18/44] Added SSO free for up to 5 users to several docs --- docs/my-website/docs/enterprise.md | 5 +++++ docs/my-website/docs/proxy/custom_sso.md | 4 +--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/my-website/docs/enterprise.md b/docs/my-website/docs/enterprise.md index 9101d8e3751..cc3466fc103 100644 --- a/docs/my-website/docs/enterprise.md +++ b/docs/my-website/docs/enterprise.md @@ -1,6 +1,11 @@ import Image from '@theme/IdealImage'; # Enterprise + +:::info +✨ SSO is free for up to 5 users. After that, an enterprise license is required. [Get Started with Enterprise here](https://www.litellm.ai/enterprise) +::: + For companies that need SSO, user management and professional support for LiteLLM Proxy :::info diff --git a/docs/my-website/docs/proxy/custom_sso.md b/docs/my-website/docs/proxy/custom_sso.md index 8e869a11393..bbd7f41bee1 100644 --- a/docs/my-website/docs/proxy/custom_sso.md +++ b/docs/my-website/docs/proxy/custom_sso.md @@ -1,9 +1,7 @@ # ✨ Event Hooks for SSO Login :::info - -✨ This is an Enterprise only feature [Get Started with Enterprise here](https://www.litellm.ai/enterprise) - +✨ SSO is free for up to 5 users. After that, an enterprise license is required. [Get Started with Enterprise here](https://www.litellm.ai/enterprise) ::: ## Overview From fda7e22a4510362138c3897f3fc231d44575cb3b Mon Sep 17 00:00:00 2001 From: berri-teddy Date: Sun, 21 Sep 2025 18:35:47 -0700 Subject: [PATCH 19/44] docs: add SSO free for up to 5 users info block to security.md --- security.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/security.md b/security.md index d126dabcc67..2da073661c5 100644 --- a/security.md +++ b/security.md @@ -12,6 +12,11 @@ - For installation and configuration, see: [Self-hosting guided](https://docs.litellm.ai/docs/proxy/deploy) - **Telemetry** We run no telemetry when you self host LiteLLM + +:::info +✨ SSO is free for up to 5 users. After that, an enterprise license is required. [Get Started with Enterprise here](https://www.litellm.ai/enterprise) +::: + ### LiteLLM Cloud - We encrypt all data stored using your `LITELLM_MASTER_KEY` and in transit using TLS. From 8af3ecf3bd97e25cc3746bacda36091cd7c96cdb Mon Sep 17 00:00:00 2001 From: berri-teddy Date: Sun, 21 Sep 2025 18:36:03 -0700 Subject: [PATCH 20/44] docs: add cancel response API usage and curl example to response_api.md --- docs/my-website/docs/response_api.md | 37 ++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/docs/my-website/docs/response_api.md b/docs/my-website/docs/response_api.md index c1aa4861735..b03e4f8be92 100644 --- a/docs/my-website/docs/response_api.md +++ b/docs/my-website/docs/response_api.md @@ -81,6 +81,43 @@ print(retrieved_response) # retrieved_response = await litellm.aget_responses(response_id=response_id) ``` +#### CANCEL a Response +You can cancel an in-progress response (if supported by the provider): + +```python showLineNumbers title="Cancel Response by ID" +import litellm + +# First, create a response +response = litellm.responses( + model="openai/o1-pro", + input="Tell me a three sentence bedtime story about a unicorn.", + max_output_tokens=100 +) + +# Get the response ID +response_id = response.id + +# Cancel the response by ID +cancel_response = litellm.cancel_responses( + response_id=response_id +) + +print(cancel_response) + +# For async usage +# cancel_response = await litellm.acancel_responses(response_id=response_id) +``` + + +**REST API:** +```bash +curl -X POST http://localhost:4000/v1/responses/response_id/cancel \ + -H "Authorization: Bearer sk-1234" +``` + +This will attempt to cancel the in-progress response with the given ID. +**Note:** Not all providers support response cancellation. If unsupported, an error will be raised. + #### DELETE a Response ```python showLineNumbers title="Delete Response by ID" import litellm From 3bb24625ed569222d3b906d5825f9e492098e92d Mon Sep 17 00:00:00 2001 From: berri-teddy Date: Sun, 21 Sep 2025 18:45:43 -0700 Subject: [PATCH 21/44] Added image for modifying default user budget via admin UI --- docs/my-website/docs/proxy/self_serve.md | 6 +++++- .../img/default_user_settings_admin_ui.png | Bin 0 -> 239138 bytes 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 docs/my-website/img/default_user_settings_admin_ui.png diff --git a/docs/my-website/docs/proxy/self_serve.md b/docs/my-website/docs/proxy/self_serve.md index dff55a8ac04..b54344c1d05 100644 --- a/docs/my-website/docs/proxy/self_serve.md +++ b/docs/my-website/docs/proxy/self_serve.md @@ -227,7 +227,7 @@ export PROXY_LOGOUT_URL="https://www.google.com" -### Set max budget for internal users +### Set default max budget for internal users Automatically apply budget per internal user when they sign up. By default the table will be checked every 10 minutes, for users to reset. To modify this, [see this](./users.md#reset-budgets) @@ -239,6 +239,10 @@ litellm_settings: This sets a max budget of $10 USD for internal users when they sign up. +You can also manage these settings visually in the UI: + + + This budget only applies to personal keys created by that user - seen under `Default Team` on the UI. diff --git a/docs/my-website/img/default_user_settings_admin_ui.png b/docs/my-website/img/default_user_settings_admin_ui.png new file mode 100644 index 0000000000000000000000000000000000000000..5910154cd51f4914fc7db293183e6fa39a264920 GIT binary patch literal 239138 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

3Y|kZ*?uZg2 zOS|3#>vmm5P9(4S+v*ldyUmv=}L%_H1 z$R$;5nmR=K9>}K(tDY#2(vMs}90LXJ%hHvmDdk8BpWI-#631@~i32P?*@E z%^!&LK$?D=?2nIq1a0CV)q5$68=!4&!&g2CQh3E^4{uBsTQ}dl`>obl@v(y7qyq}f zEB|TarRS)LL}KT>Nw0}L<74@jn3TlsJbm(6@7k*@_D!o?EHiS!ZE^1tjFX)x06E>R z^Rt|AehIv>dbJZ$pvDK8q9Z}9s_QA$VNY&m5(aulW-qC#l zhp%UkF^YX&I&mQUdV|`z>+c8 zdR%UoI`vjz2p59>);l06S|1fH^JVIZ-w~aGG-G%Zd3BtFF&&p+{-g42ERouNT zPmstyVp)9+-&;GDsVNDEVtVs~3#pf>4M7k2+S!=tPHIkeR(D>0b|(gB1(2BE1}A5< zybufnqcxt<)j_f?u}3lT=I@!=rwo~e+}PB812n%VXFVAn9#r1wSvl9 zO3uL~T+R4`POE(iHF_Swd>N?I+1BKRFUGoIGDIvdPb$E%hP%Bt#(OUW4GP6B68BPMwkAP1n22^+4K$ zLG)$x2=vyWj=N@B0{nqHiG1tPZ`HdArCD()|rl2G22ekcTh{#$Iwd-N3ol@Rn8VrwvI}+`6?3x3$wn6JELv<-@3m?>#IEyl3 zOtr{x{wsL_3fq|ViIk*#Ep$lT?o`$pclUK(>xX>Ex?iGJ%h~Shc0~3;@1+iqtszbi zvH}C9NWwFPnDfwworUTXS-IcH+~TR*JdhuI=L?)gw`*j|GK1!eB{Vyyz0tJOsI72s zMKVH)cgzo09nCtKT$a_92OGNMd7>o7%frZq^4bRsS>|`E_126gY7!hv6M(nk`af8! ze`xye>0)2-s8)6SaH$tvDvgX;Ki%FZO9yCqURHCr_sA(_d84hgCq821sq%A%g9~r; z1vjF#wru`%nsOqS#ONe8iqtlLLCPIeZdxk}=2-gfi7+UQ0oQP~dY|ke58q+b^htlN zzd$zv*~zsqBFbeLU+oBA)J;wjRR_2$OAe1Pb`CEpil`yeARdp=5<_1K=a(LR_d49V zS_*0xR^6{&CesPoOK?aw-=6$n;LSRMcI2veIb>klX|j&x{8KH)!$#R_F2C9=>#jni z!a_HkN3~*7#Fu>IIi?a<%(MYAZJiMjs?~^8h6N;#IW3gHCz)tj2Nq^C7(2ns^3_X|7mkNZ|e24Z(`GHb+GB<@w7>n?eGS_A98ZePBWO^rD|N& zG{GR3$E{$^&$u1!C@`rN8Ou@6gg#jn^cHq_P@>IGDwGmEnER$_I&uQy4SgFbbhO@y z$y(u&5AqB>u-;-(@Ly}<{dbgf8A}`EZd2?ynDy?PB$Lf6*Yz8l`!%QwYusz#f#jH;l;7bi{;h2`ej`GtX|71F^Wj&mPI#eDS|xg zhjhKz%g5t0slhdEa{BaW~-W^i{vl0+b44_!xN~mb}x=qK9pm!&U8K)$0S*2X76s~1`E5r*0a_aOb!v|QSmx8 z{jyP{B1;YO&ZF@PxUmuF+p3Fz3TZzTjTWGr}vel##Ll9lH4yKRoR%fQy9+A@BizV)~{ zp?2a>$EibK7$Fk*CqpIA#q>3oZm#-0t)U|2YoR_N*O~W;jJCGIY?(51-_fi;oeSPk ze!`NT7EM@D-P%Q_ts8dr+QYJaoimM050rT(>*LwpAKqBk#qg&@bz8LWWWOcV+_w2C zc?X-@cq=J9DIawiN7+m9PFEckemyR{*s!uoD}hP0kQ?2_Q!{igPxV`PfRzmAS?DyW zHHF9>8%$iv-5~?eum^5XbllI0#cnM+7^_C1YQMsMe1`YyG>eI@y>8HU5b@~~*4?^1 z+6B1hg@=F zi#MOFhjzkXj*bd*tg-lo0#Q*+0)d3xAzPfy5+00)b*FA06uT_1@xhVd?D5`KEInKL zw%75KT|{-JVu-Ud9pEcp?1n>e*yRg5-C0}?gK(DMJhTMN7D_5tt=lu1Rkju^;U!w^ z$t2k~U&k~y#Z$?J@}RMN1Du^?!)P_vnIuR&+`KK}(Aow6lPe>sDi5JTj{-B&_}#u% zpYiX?(>*sPTVyzf1fdz1+@?ZmD{{ zj{%DCB-SC8>sk8^dPz*Q$@o~uX5c93xJF8rdH(?#SeVW#PD4M-@ttOHF`&5O_2ZKEO!sXVj0&t3t{NK z*IeXHS~YzCq^0w>d8wQ;>1sqdoR>R?=xS3C*Q%fDk<}X-3c)^AUZGsa8D02lBr=XE zB7QUW*_h~dl3$P!vl)OYz_>?%x%UUPjg>{-O2kc}r4FJ$r#VS*`#RHKp&|9;HX=WC zd?y{8_J%qpZC|t3V!XKCq#6A&)NPxk3xqK@BJTST-W9rqhi9fHQDC#I#@E$(6^D|A zW45_2fg!6wl2T~hI+NYbCn`~FbmPzaj`km|*Y)P~$elYsFeopGs_-aj=>vd&;RIWy2x0cy?Dx(h zPsOdEG*2u{l=6r~(9$#x*`AQ;Cz;R@sa`xKlh^U&O31gnNdCZ||=1Kftbk>9i7dB~Wgb zymOB=@X78@@mB$tY@$mL!W7=bG~|56Hg7~0?3%_CqK$Gkp2pvo=~I?@@NB89m``|e z?{b*)ZBz`M@LJ$t9dmCTSU?S!Dj8%eLF369A0lck)5s4EvOD4e72l$Fq-}S0SLIaj zo=6M~Tph_qzKUs-ptKIOJv9hPFI;oz-MIboJ8uhfyTNtuBVm>M`E3z~^oL74_1FbY z%L+L4z|gJi+6;S|EVR&SK59F2oO|9B&LwYSyF%^rqqQbU!XtB$btvuY^E6SWwNu5*Ry^`43RzS%GqBDV`QL;QtN(_ z)vzw{6Z5HkG%m{xL>Go4eLezihT*SYx305mMlz9!?8T{{lE3$Lbx#lSd?LTdfuow^ zz87VXqs7LO?X<*|vjgi8J6?0V>)$mV?r2E%ymqTljHawUNBL>K`@!0omuqI-v0;s9 z6cr_?G+B~~cq}$?zu8q(Ul4;Qy)CsSTdn;qXR1@6yrovR19h;Mqj^qjB_}*(?q@4n z)Z6(zdMWcng?Vk1?DM*pwr_&7DnGxo>d&<11|PGJQ`Jz-Fs3=(TS9Z>y)qH7F4TNh zy?Lhe@Ja-q6&~+S%K-ByV`|c3<;s^Eg1d55u7dJsXM8Rc!VNlQAlJM8`*S2#<;FOh zqW5=pYPPoTeKslBR7FDkuLLM!Q1ktV?jw1T>Q-xl5I2am6Zh z+*{CjLM%aHTC2<~VKASt+B)M&pxX&ZRc~Ho=9{p~5<@3AV zdPTZWx1qV`S3_L;mER}6bCv3_67;lJ=%w3h;3mjcd27w9LKCg{EgVEwkNBUIYc!6a zpFEI7X=qJf+YRv-6USICEC3AJlmFv#p$}oS@`1x;1Q$xxg$6qUxG~qABQ{C4 z9s$%Qf++nW0Gkg#r-&J!G&TWHOL(DC$8?&B)s~I5LZ$XS{HJV1XacNNzp0M&Q+71l1x& zbAa_uz-82Mo?S1WF-yz|ggn3a{G*Y*o#%LKu~|%f`jk+JH39kM)jvxnn5s(3A(o8X zR&jRI2dlP{>|QmGka#W(9;_MDviug{iw>v6doWPIe><&yv;;5!NvoE8agMkBd<{T4 ze6ql8?oYiayhsTLbRuDplr(M>Uoj6PfPkv(C8EZrY&}wei}gHy13UDs|9X=lX1{;P zgV90tki?9auPpqLG*hK<$qoVWDvpbO_45m-CGt83a}KmC#pK${AxLab*aJalwwVPS;bg6*R*Cy+qplOdvB@vWI z(&^F2bXaIC6At|$kKv!iP0%MxSC7`e9#zaGI#03!SV$)-g=9Tdzel`jvqEDS zz;3F`&7(=`I{rX2(4PB=T^=j)?himk%ZFmBP<~r)P`AMsvNqiL+npL>EWDrbets}K zWUN!;=L%3y+&6DgiM(>!kO|F{KM0R^*L_819qRs{5nnWmz!J7h($4x~Y`_4WQu?!m zh_4(vNY(GPAbHaSAbjHYQ9I_(AL5;=6>7f%m5d^XGbrtk=F9SdbvrZByU=LTG9%;K z%oB!?whBvY>lpoKCVFv?<0?{}_o6_md~-vVF=BUPvRW2$fPndZXd^h6=Gkk^^nt1h?*z0ovhuIv>)`4XzOtmQ zK5Ajfa4H~6t-Wc93QoO?0gE}+(bI2@Znsq^m%VW0FP4No6O{70-ofT_xU~mP2h0^+ z-lf8?V3)R&|8(hK-yi<=^XP-Axq=92-gizfP9<&t`-Mic3<><`H#k}10(R9r(4yI z;Hy&@ua$SCstaaZ!;Sbcqt9@6D^K}HDSmO&Cp0gr_DvL<9-XO?j8Ty((eHSxD($%V z)c`;Eq?mSON2I}e+nJwd;FTauGI9O)b;FGsyaNiA?!mt87Uui$YjVv^ZHh0UXjyNa z2e#X`04DYazu`s0K-z=~IG2DeGdH&4c;BpZ=8TUB?#IaqdhMCFR>={ukN1TpilT9@ zRGPVp6}H|}yAC24y3VTqtW3|-gk$~~jPIo@NB8=w^c^!_-_FyB@(pOh-pA2uSIUd@ zgw7bd&NlO%UU~D};X0K7rvcO-`yD~mPztH@?gDOq z;2O1THO&qXw50>83HkO@{zs$Wqm1b@iV#+^!t@z2T3yDYRA-@P_=3~yNmt_P1#lm| zhOo?TP+o2V2)aP{=w{!?`CKYuL&qC$lxw|t&sac5zceQ&XNFPts&bZ-y!6Yqvr<#Z z=JImxHxRcVAt98j-~gz1eCJXS>|tm4&F~W7pxQ{U$|YH@fMp<7jh@@V&0hbvCt)NXn;=;>~)3=g}~!R%LT7M}+N z={bn-onw~@-dj(V0u*kpNph+dDU6&;GJL77G2F3dQIXQ1dkJV;}F{K-lYxZs_B>wee;rW@N^z7 znl@=_#_Mv;)0l2`TV!xqt~N9>=%C>mD&?D zw|Tl8mvy#K0qWR6}pmkQ%@0ji1o)BI$o*eLdCHV21lo|+X2C^t(WqoP<*@>CvsZ@gjiwjb%*KkYu7a290^ z8!>{iXjNF@j{lhTS5a7H%}N;MH#up7C5P)pvEq*}7Oty6N+)^hlA+Q~jP_GUuSJe5 zlJE`=1-qWnsrYUGo5n{rvF)um{@}K*@UGGkpO``~2QRuTSuiykz|UT10*NTn=xQAC1@;c+;Z z!I*X4hAmQ_Y%_osE;cQ_=xJ&0TL+$5O0B)GkdZ1V>|FIT=b+!*Weu)4Kc@)YV+nI> z9nP)@(ROHzO@op`LdHEYhOs(J#rN1x>*(Ob zZ)b0}$bVwNy4HK=hW8udJ&`&@IkLf>l6%={?^Qr&wQ+LT(n_ZURE5lB(6|DQl{Pti z@O^k-|4$jM|H;m-=Q5v3J|3aeu_qTg{@So^QTpyHskY|DH-5ZG=eep^S1k_t$`zO0 z#Jzi0Typ3yq3Uw%PUe0!k4;(Ph3MEfH}k$FceB#z4T+?3o|aHNo{n^v%UjeUPL#EKfP%dD!t^#`#5u2XI-v@JwUl&TUbZ?E{ud!P+ZS6E=pkA|f zYT`M20TSpA@8z$FTav|*i>~O888DYG3elGGL{aYzH0U0t`Q`{Y1GJt#J{mL( zaQ1vF+K5RHiTK(h*n|;)7~OvCO_? zWFuzLx}dOr?MVk>8rqn-lTGZqSJ;jkOgZDHCs>A@o>C%%htP!%2vkTXv&t)OWx*QQ zET{Du5`?suOa6=V@etE4{Ji)UCXA0D^4T%+{~h>6mH*p;>s_HP_WjVD`~!eLo1jc> z6yozprM&$={U(oMbMzo~Lufg%4&3+4+#wscaX+bZucxXcpXoEt*6$qGI;}U|Hl=HI z+%$=H)ExFCA1>?Egm1v{ManwKyad~^=;L@z4YJUuhV7iLh>yJ}`@Id%Ad>~AA|meZ zcY#7iv;{``6n6m(#=M5>I(+x0piW_uJ3zXka-8eUagZzi8FeVlfj zvh^y&Y}V=bU~Dc3;p@Ds_{E?UxVK9v+Ltl6SC?5!F|S$=3h3F$^vK8Fx^-Q;zDc`i z0Z6owQJhjiaF+g^#6Pd&)kSL4r-NQ8*5|@JZ`aw^p*^^@2N!3ejBc)sJDI^Nr_qB& zbt4(u4Fi?EkOQHqkXLiy_?s(K`cnnr@{#O`XRb$qLpz~3tp#Uu91}d;DN)mNb~mYN zPWCc1pC}?Evowd1N_k^`>O)`e3GHnb!S%1V>sxA0n6_)68eZroqZ+6`2mp}a3h>Ky zHBOGkCXaRiYGu^0;AVEObdwU<3KWdVLc%aL(Hb^wZw{tr=N;|g~-6jzWf{WDvMwaE2th*K( zK+SwTewuvS$jz>x?RdXZ^!Rqa?nlb5(;Fy-mBZFpGjldA?(p9k&)@9rDvQ3(X*Kav zDR5YY_Vta)P*~o_OPWT^d!Ak8bK}ntbmP#u zgSWd9P2;*W6DJ~h5;aGO>Cou%u(XM6mu0i!|E$8r`Z0a(ntCrVXFte9iX~LXe{v~N zVPc^BEp6u;^Fb`9ruiY16c5J*o$W$0qkA^N9DCfS;v~reSt+j?A6&wOeM^P+YP|q# z(Bfxn;rh{bc+S~=cFvVd`KveriJAW2>niMqsO>^>A~M(}2SaM$xI-p=W-}YYW%9^1 zf6r$uEN-p$jR3faz6iKADb#YY0=BRF>RmnUj&I>f`{k8pwR&%DYsaF71DqHOw)iY; zsL5UITb)j_A?hx2oZ>8&;J`SzSfYRbCbthHkpAsF4_>}0&vOnB60`MvT;x$?j!71* z(rnG;xfUipkYXip@cD^~^WM{aRz$@>weKtb<_`%qZ zOyyKwye7Q$39bo*O~HKSuhYbVOqIK9NOi4c_xxEkyb}KkUozKTUQuuKo~EwnoG^&y zT@Om#bev<$IbpEcf)wvo2}-Mm)Bp<5Y2$tqxLwIzs_R9E6E~=i@3H>pe6P~{m1=B! z`KRxkL-npP{LRaye7oV2DuH?g5`N2TBWT)mc{N7MAf)-*Oi29{JBLoiGpd+_Z#q_% zzi#oxDM?fXsdkzxK?|q5@ksn|-yKofzwXO*Lt%$Ngf%IOp?eM6GSEumaYh_) zj9!ZKJ`wTB$R_(YDsQN0vz@|&{1YlS<)~O0o$*Q2SN1olf|E~+b1V)kg^d8BDw9l~ zvUdG!)aZ)Z#{;Zf2hDz6`YyHC-33Ox#R$9#&5YmzH9Can432vorlYcePls}dY1cjB z_NM=*$(dPZF2|ku6RNVM-W44%giVFoV*uI%x+D^&TEEaI9Mt0En35Z3k{4P0N*SMu z+4a%O;m~f=I9Wy%+;NuQZi*q>+m92hcdaT;K7i3=D>Sdmo9o^Kpf?KFRq58x>iW1N zlPLyOeP&hf9pcG_gnp}L)NSU9DUELwAzjx#^gD&C8`YV+h*YUB?HY&f@B#Gva?b7VM8O&zvB4Q6T^P>GLK)Y(7mvecXad^wg(X1(-O*9Nyo$?}e zdbh%h^A%uI3;E#^9Q}o&d~=RaJNm|^;^!9U$fk<7?)migzk;QLzQ@+>)~xOma`UTi();Zd`q zz6TB~XnZNGCw_(B&7%rb^J_{^_@w?}iuEvtfOq(q3DlbH@+>tA_eyPRq#)T#BQ$Y*-D?GzrHKmL z`E2DKDcENUPsAaaMysaQxg$0V?Le_atu0~Wi-Mr9PT)k?y{3=Zag%2m5j-K6(jBiW zq`dW*+h#F%`&-VfgPF2yOZ!23QG>2X0t_uo{hy*-JYe$TXse531I;bwmgCA_2)AFMVZ z*O(WHa>$+R>j2D767cNFA4EFu>57M$|#ojMfM3Z=Z8l75IA0*?=4#A6^cWMsJ z)b!dJi=IEZCVCQJ{((VTmJ2rMRuGOe?8tXnut<_zXfRg+x4XO>ynMJC6wL$0N5HWMXXY_WNKMaCCcXc` zHR5)feOWn2SrT0}d4Kq{S{;MIboJ`&Mp-~qM9szeaF<mc-DKaTny?(4` z&HQ+nOHC5OnO31?-JluAWBQ2vY;4}Tlj(8w67NTmn`A-`68f7z0aP&ZqbB6L0_C_& zK^)JdjxvclI2aaHh?UruKfiSRto4S@|1|Mf)E*ta7id4y`Q=S55wbfBpPBvAN=dhr z-*N&MMT;E?aj6V8%Y}o%^ zDUk%755*?`hqJGaigNAVwhIMC5S3D-8>OU`?(UKji2>;x5Ca4Rq#FboI)@lw2oD|7 z%@ESvF)%}X_o(N$&i7mIcV5@`AB#25^W3@jwXePJ`-0y46-M|x0L1n)GUvH^#S`-k zw}D8Lko5;7NiLP+C*Mjk%$DGh&QC`v|E_@Dppf5uy;ixm{o_$r3pl%cG(*J_(ez^~ z**otEac%mDu5%8t?~`teXMVxgE?ga7jsQoX)`zE>0!tt-CX-&u%({m$8-}#L~)}treJSe?b-EF7hE+JW0+-=G%EOs1llO5g_^LZ`A=QE^MH$S{7 z=)NzK2-%U>Iq#d5dX;hM4EO+IpE8mq^WGNK{%W^{q7z2-82=|B6o=xU^`Z+*olbF~ zY?YRNY%%tO%=WJv3=_g`V!_brM4L@^l^9K|yAK18tJdAg%Qte-=8~z+f9~ReM zT_-BgM6(n3;|sPp+cVLSd&@~$7^i;=>hJCCrQs+_NZcQ|0jYSH0Zy7*XB;>-cW3*a z_x-eUUK4k7f~Y(U365xw1b7fLX?zYKZfh4r3)>L1gPOM+eq;{2q4qs)<_FP*JQ+H{ zL}B*z@7*U|@T*2@Yq~$oF=@2-UrsKGAAh5r1}aCyS)nzdap>z#-YZcZG*6#0sAzg) z6I_JKQl8nq(=f6wF%-=*$jf%KA^!d(_l2l`ZH&yv(YlKS)h1kv#f#CfGR#QZfK^q; z{@ij0K8^i66wZINW_%}2I(S{$AjB9H-8u8R}R zvv#6#SJ*hl%r3~Er;yKVB~5z(vg#Ni0rs_fx7!SiMBTso?M0d!x~kWl*rb#j{rJ07 zcq^&dM6rYOY$FYzW)(=mi31mYWvB!Qxt9O~90m^h8NUmY^*WaK;`+-T@iyH6l{i11 zWu2XP$5-9pt=|Nfga_2%R4RGhW8yIL(d;x{=cj(I)qTLyT;yV0oD{C!{I%b`hTdPE z-Pk+T`kGMF1?YbBbMSBoaPLt*l>gBw{6vzg#9)Jae25MIg)493&hj789#y%<%6Y81 zA(fflS$ueGYuOLzSvpE@?ymKA#YTETcEpdVR6{S~L<*hwOOBk5BqSu}L)Db1vW?HU zm<+C9I30k!LHu@{_Yh>$yhRl&O*u zlsi=EJD>Ll8BTi~)IusulCC+H?Z2V-a#f!z$x+}yfw+7m*d3&2OecnfhjI5_kDo_C zP^`&N4#Nqq$eY#aXW0tw$0JyFpCD#{htZ5*s-}}qwDGe7$J(z-8Ibdp=nrM3QO2Dl z)E2NaQ3QkVDNC>XuV6vbZ}KVyHjq<+9%k1mPN?5{wa2Tn4G?*Ng=8}E_0YK#Km?o5 zPctlXhK{SBHH{sAsSb{#6?kggk~cv!f1nLY0gRxOLdn?Ds@+l4K3{XJMeD|W@7hFK zRE|g%zZXYeldyo`CQ?0vmGY@`_Uc$9l|f_!neAiK46JpA9N8T>Phni&Mn&P}m>@p# z%1g9{Tq&rCel?VQPM|aIG&{rfoFm|atU8u3V_s8HTxxE$S)(~DyBiYaCn=~@P?gi4 z`2`sMI0rmXptErRBBpR~Zmef}R_(^%@dhC%dYhyj{C_6=pbq5$D7A)BnkvYGz!U0GFoB?>y z>y?pgpPOK}yj8h~*h^oIRxnR6V#2?~O@g~Z|EAMZ!0Rr(|9b3z!~=+?(+3p3|$I=!a!StOo zYzWp4=24WbSW2i75Axw?w^zzak5@*OlJ%E6h6!ILUCgL}PIBPU)l z{BfYlU;XCSwH zlIT)zexwX%an*7Ve|lNH9YvX%tt6yflA1tzzhA}^I2QCf|BNKZPYF(#t0MxX#?z<8 zE)*|Rh;&JX0^U+dalUw-`o*~6Q@~S6xhwM@Z(Y1?eCO6PG4_nWp))VVmE}HO`*@A& zW#b#Yw*jdaZpoca8s-fQe&1j1T>Z|9Y}f6omveD)Qr|;aI86#Rw;L^Ozp$8|S}~b) z&N3fZnovH)!uC$<6Oyfe{O~aDVbmA6PLq*#XmPrdnrUfpN0x+6Mz)l>$5w~vhGL`S zIR_?fFH6{dcSst~w<+ta7QT1TfYMV4Z67#r@8|jFyET&|iWDwy z++JUf!cID7$R~54SPaLp0#@B$k|Ldiod{((4NM&qB`H&WWb=s&StFE5UF1}2kDAiO zRODl#G$7Ao8FiHzB7#_#Mmh#9;l7n{?c!QQ&3PM_b`0-Mze#S#C6;L?7AZcp<-Uu` zVp5Sg?k*t(;X|p5X^IlcGV0Vi`V%V&uJJjagw}(pyuEIUpV_0)+*0c5a&R}8b3s@Y zEE?@;p z9_M*yiw%)KUeXIBZ{Cr+fcu4BHQ)rXFkRkqG+~W2g>un`IOZj8{RqIk3Zh+>!MAm zlVu;JwOC!XY1S^@8`_hf*|3=GEF^uj)fKJ55+~7fz+FgdmH#pzh8I)1#f^@3vnfvF zhLPtffAzIBu*{yTaPTf-!dwqPS#`#jSgkZ96x5sy!caHN3LLkT2qR4y4TE`+1Ym|9%2pAK@`@WZrEa10}Iyj z5R-w}PMSRDyK^$eUCt=$^;BOd`Z$E!txZy)GJrq$PMuo@9t%p`ni!_o+Xe($9>u== z1ih{A&1fa2JR9Mw3(Lh?OolaxLKI18)JdY81`YZwCGF=(3>6#(J`fQM7*z^nwzxu2 zC7j|U2|^}O)sNlvn1yZn%OVrK>`8bCpi=B%X)BM6E3IDb1j+Z{?z}hO5MMBv&)t~W z*J)7~=od05?}K+9;`az5MlEkZ+B;BLZIMcuVHYZlI5lBsLh5uz>5{#SD4|EXkE@4dbKZERN)a<+;WXeD$34j$24; zr5x?PoZOwt6kld#LRt!0n^+Li_W%o5Np9eQJL}Tn4tnyG#Q%HpXhx_M*`0>^&zIW& zZRPrG)4Xxjs_|SR|0U%2`MF5gn^QVv^G-$a-Z34kJFsRs3%!e)b&feBa)xi;%)&sa zlbQ925jA?8xCJVGgJw?uE^KQr^_{J%PJ?Kv$3f36hUxgpA#Yjh?YD}Ru9@7Y(8a4B z=xPG!j`5_fxbl~yeZ9tdcET6KTTeqXN3zX|3mNU6>PpM-%YF;xoqu#h<9t^=$#r$A z%#He$FAOZawTOKu5u$1ieE~ddw|&e@E~svACG|I-cN;;l^j+>sR_eDkJaZ-p1|kCW zt>N%4{(J1u+SmG_vz;GPPxBD#I(AOlXeN1q{xdSCIXm;JpK}yc!l+W#s+&7mVVNY5 zYb+F&x$IP_gW&-UlN1XHD$MptR8$7zF0aWRa#|R|?7UviGTEqVH`HrNgan}pu$1;Q zkyF`%$E&jY%Q2}D?F^)EBlT36vE#q&+H%HCbbWKOg46SY<<%;W)(&T`$fSe*M&VI8e zAoa^YjwVllb7_*(a7}gKMDzr$g?{f_QyNP0Hbm%W8f{3jpi5b$)5^i}0Y4EsOhp>w z`}NT?>AInUP~N$~v{L9JWphL@su@tSA*na^dC^3Z4bT-m#^d}{P1uHa_gRnOOL=j+ z$@Jj%99Djal^;b3LU&LyrVJfG&jp#0xG@9qn(FJS`J_S?67+Q}OGtN%J)#6YVh$8v zLJRlcw>j!FBkrpzpAwPV^wCq9y7zbqdh9t31h_hFZ%M*Si0!HXu2=gJnw9S$KqBg3jcN z0Cv2u+d+@h;#1fJj8x*L*PY{w%9GMo8Vk5enVr4l|buryUmn$G_ zu<+S2z1F0?P4TP}r9TG|f%*3Y$nm9#rO`B5;Tl7z@ZtBs{ZUfeifq}hB|}T_{3b7& zfc~skiBo&IzShL=mlhe8_Q#F}~>aSP-&sCDG( zi_78*g3+l9^W_NIkr*Dj7?oIT7%N4#&{B;?b+8HIm9;qEuAX|beCvKvW&UZN^60`- z1xC{00+*uvooY%V|GlHY{2=VWUH6x;==OYySGC{cH?rB*?JD!C2jiCH;W}LlPczOs z>I-achBs18EZ<|z((Jk4S0FB21c%?Z$95l7+?!ykncU=0KKyV@&62Z`k;*ws>2>7R z_A;?xzGJYIRgzp!uBL0>Yo9zSGIU?DQV|Y4wK(0VNuEiol@1pUNM4if`U0t`ZTBHO;F zn&gf3n_BHAhePyS85cM>BEDs+<$PeqBRN^V5L@Y_StN0Tbe5T>!?3U$7Ctye)X1Tk zm6c|4c#IZaMGK!40%;Rg83fWETx>LvZl&5A-^HjR~{vbkKBP-FRc3C8p7&=*M zIp7F4>xg5TA4kkHpPdJaN{{_N3F%vvkz0BDTNCOu-nmw0kiz$@xYDfc6&U5e%BjpU-4=&!>=goKtIjE8ZL--fh2CR zx34Bq4!pB~GuYj?>-dl)I?kZ0U;XP!VK)t>#9_@TXW6>PxP!Z)sr%{N4uz&8P$9#N zaD3mxFmPI7{#|tW+x4x_Ill#@m3%Jj;<=qF!j+w5OZ9x{?_E1L;QJ=&B8h=JG$DU? zb|ysb6Z5@vf{BWHZXm&lRU^GbcICo@f5@u;=RcK7uRNg8$lGbOeu(?Qmw?-wy(u-f z2yzZuO))p=ANVM<)!ICgB!<1$Kmgxp#uCpHl*+~pPxekSpP5XWRY7AOhWAQfA)deO z+`oU4-uB!_zd8Aslb>HZbM7pFcxNsJ0@+qM5r(9VlP-3w=Q59-e(&=j`aLIvYUSvq zym&fD`XA}Ei+s2#B9MFY`}K3b^j+zx-nh0zf^J2K{aACEL_|x5lu~*pSb2*x#6T^y zYLoIN9m!GNljGKKbS=>}}$+pFMb?4SaohZjxIt>A8yAK$*wq zxXjfb+)nw;vOhD_;zPtW}Ow*H?ceS55UX6toq?S(VH-bXBbW~-3O;>n#qZo%9$ z@Pt5SFWs*v9-Id)xc(y3_qTTUzsaEgY{i#0+}u6R2=M=@o-X3_0AMKcPO9c#4f^j> zqg8hG0R<~+A^EEx?wdZ>Xu%8q`gZS+R?g2S-rg1wVCGffd;hD=rr-rJu{U)8XOR9| zS2FJdkDt@zW*qgiwMyW2Y6^|;+pR_-|F!qOeh_5@Hb?tr>q7Uhy*Leeq#@uS@w-9) z{WV_-K@0HO!zX^XnT8Pb=oSI#@6Gn#2Ql*;t_$C!jRSsl0gCHFvO{a=fB*LHE~w+W zupGNk{%d<=fmzjK9KJ{NZ&2xuMnEf1f$sA8KY1EFqD2daJw0*%>TTNLGL%icMmyuLHT{JhH@fwq27kOF zwA{fH=l9=Ty7BmUWuh91C9yWX^=l-QZi0cj_?Hyjf1i{$zea*t{9OAd*Gws2#uMFk z!t2nj-V{ShAy3{=0>SGwzZT33TmyZ>g8#gMG8eL|UFmyp0(@;BhuZxJ37`+B!JYIx zKf6p62h2)}e=p^)0r)k=|N3%b`7b(weuVh-SNul4iSvE;C4~I^{g+(Ow04=`<9~hn zpJM@TxX1=U8Y{UCa;=OZE0TuVR~g^w`Dc8lb~Ei5H)eyXVGTVy@f_u^5=MM!BsfEN z5oa^be02OHn~UqrHx(Ksmd1J|zTLW%nsAxnvd16&0`IWEg-;IZuQ~v&D1|utBEGdp ztqN}w#gK)W`5JjTM(We>rhef_>*hUji&dc5h$^^)#c9|=D(0e?Gz=+-4L!F$+_?9+ zfmR{m6%hM$7X3u_w3mDI)z|I2!9uu+nw2}V#cpo8`po3M!fw)ZeWg1^v^TvxUp+^o z42aK<0=;R7$7+$J@r7@Jtd%VkZnL)#T?bn;ki62t05luK2#jJ5k-=z+_K$fz? zYV-sujvEr!&%9BYrJ6DN*JVoU+7pbGjNv1RPlY&*YgjNbZscVH&rDDCQgxO8a|VB} z+y87jhyp}P2?-n`@dX=R3;PGerar>#&+S2hK=Kx~76sb2d9{vS7ZRuoKHDv5-kp3YJfP3mgZ zPG@?iUk$y?)OiAdADXvo`x@(W3TpUlGIpC~cm#mNq01+)ckipbjxn$nzYjMFXo$0u z3y*+4+&&PQ9DULy5Kid73AEhc9Xyi))^X@3ckM z=#cr!kC2=JH;{~vfY)zx)rh(M#|ER~W~TFW3cir$;p^@%&(ggaWn~3c$B_s@pkZl) zFY0@}2uLEMSkTJ@?Fxc>YKVm0P!O*}{ce!HjMlUFUybvLzZuglCY2MM{LUI|9In}J)Y9dA2 z2H6eiyHuF%6-a%OHUs*mHlGf67EqQsc`t!>YH66tV>X?V79w%`55yXUk$~uP0)-L> zQ-1kgD|rZKR9zAE-43Ag_p&o#X`b<^YICYkSioqGB5}|Ziot#sC?(Dg#&cP{+*=t^ z9Ie($JOC22_2M;Z-lW!rJ_A76SPPjJptq3Jf7uCmEj!>H8e^6I)MvdB8K44|Of>PW z-fX>+0GW_yIfII^_#J7pH59_7F188RPzX@rehrVIE*>)QV3~2WzePZE!f^kKjC(Ed zLki2WnGoTLrcd|2io58=NosL6$JI5Yv&WXnT# zcRRmIhLn-ol>a4!T`evTxAW--bJ+2IA`5F@Jgj08eFZ=lQw=Z1_nI2?cx zUM-&qE(W5CfE~vR!;h30krc+s-Roal*ma)5cx+Ug-yr37R0nRO>=->+8}o^zEcyri zemh&OrxwIOAc8u(0*GqUn0e4s)Ev&oE3C^}d@i%!8_*;PKc!|^2L?MI)*!|qLvp#^ zX2J^#(KQL;s$)cu83;FWpQxtvPe3ud{$F`8&Sr_o$z)p)@R@>@MzdqY^v63kGFc>& zh@g+AUWr{!F_O$iHKJTeu77EZDk-WDt*$Q~0VLdeh>R2(B^U56mV_(twXz~t;SE_} zfEY_R@90`jVU+uk?9+zgAN|7i9Pz89GKdb9|1+# zL~Hto3gQewkx`W|JK|WOJBUfIm1P2fO%ZfnX7G@nLy+#EA0|w!{Pomx{?)?z=_~JL zc&fXEu;oPU;dF{+z=wPTBSnfZMA(dvc}^>&USy-d@Nq;mWS%%#1BVx%S3Vt(@6AS# zo!4pWhfkjHBjbv{%Oun#K|x#jD)B&f^kx;7PuzcXxaezM#eCfUQbzJ<>5u{G6QW10 z*+y=QDSmnpVgu6b;QVVb%FrU3SWp$7uDF0($*89b3AZ+qF-b@%@WG(?CWD9tt0JBBKKqLYjHp+x8ZVf1rNNHjq`21Y}EvRO}{kk%)F9yTGM zgIQ>cuK?k1r|p>cV$zT$8j`KePX3J&Ajn?HivQbUkLkMscm}z2{q76IgnJID>h_oD z>*`zms){3EdoGkoFM0qiY!e3M$gp_@-l+>a+eRuSB5a{Dy|OX`WE5X7h{1FXY%1o) zVlW3l7FMnYzxwWB9Uhw&sthu-mf}jUt<)Tr6m|%-PvQE`BaP{b$?N-l99Fr7pUC+K zE1lPtv#Bq6ZT@vxLkB!n%van#tw~8bL@#Sad?<*m-uFiFS{bUdpwd>n zvfnNnr~W!FTv@iAh+O$h0M@J;ShFyy)U$uSW)nmr2?<1%*%Pg3m%0&nXm#Dl-uL3Q zwr*aspOHX1e*7lkNY%~s0F-U*IA(8Up==zOtTOC65;?c|h;(Zc$WS}qI$u4UaZF5X z3+(7ennQZn=mmn1A)gVETqw^?|9*kmn;XX15y z^QU{j;rDJlv7Hc5gZ!-4)qEQ1QC1l@wR0+ zyUl`L9qH@jTgiP<;7vkou?34ZuqO`V)kWAq4m*!0C$vFyC4&o$2+7NShDrZea{tdi zm58sX-Py5y?VXY2o~?W1L!Xj>To}{wN!$=Hi7OhSc3X>IL&|{RMPfj}&E*Pjx zS_2O51duofYOjvpKzoS(RJ~oUrXjTLF9=m@KQX|jyU%z9Vg2O5HZ(axS~*OZ?(!r^ip5*K&eGv zlSD9?1(0)=ve*Gi<|82Vi&Ba1Xk#jX_B9rEb%y*VIB?S7paGKA}SQy&@hlY*?jf>#nn!$2A_q0(6 zg#JrZXfHbGy$AoiwFQi=kI3! zIT7^u0%NHwuTkBFot~>tr2Uc{>F;CspdPn4a&xY?1M6N%?lNY#rM3?6jO$bA!-!b} z=$^V&PpbG>8a&W1E|cnZlIM1-O=my@8W<%VoK-OSoG=VGYj?$h$YUB(!5l$N0iYbx z89-CyIzIm@d-$1Ht5yktDiEFId7qx-uK-!|9uTrb^syfX_%%~_3b0KL@g!BEk#|nVFyjOdM*YgkQP7$sLN?9Ob+F@@oozf{@>8Yg+6Yb!Vv%&%^ zfeVxKmK#!c8yAwjYf$OOz=>9s>#KtZZbS+b&uf<&j|iSiaOm_8d4?zRP!LGEYoY;C z(aq7wQz^qnQhFctL@{em_i{0*ywH*%B=E+r@mQhC@~rY(J^}2j;2(8&|B2Ngb6*0D zBsVZ26A?kK2jC;+5rd;kZ_eWvJ{0wB!200@IA=iAY8P)_WfT*-iJ}-OWQ}k zdOre1X^(h+2tB2S#@6eTqeDAtKJhaVo9Y0>%K4enlonhA!J3@%_4-9`AOv5QSJZTW ziv!RlGlRwEiinyC_mx=F_NE}xW>o1-88={_e+C<79@5h(_Zgv^02)s1E6Ysd+^mL{ z(T4~j3XfF|}xqT;q{jPoPK=E!4r^g6ku$1b__K60}TPrfRUhIl6&BW-vw6ue>7IE*ZM-KUDhQ zteP^VI<*nwIT-IOzst}=N)Tmb0Tw`3TqVz83DFxaelnQ!5okl1pugukzMsRXbSht{OA*K z;c&>%J6458Hg(7B$2T8m03dLPy8nf@$QeIQPbLM3^uFHv{nWZv=Es$KwK zYdvO8znk?Kgeq%7p#Ftu#1@2ME|0&=iX;}Co}g>F0rkMeMV`^LbTyxPaJlxuK1H&PB+~?1V^wj&S zw-dzZeJjM+a#yEQQacVy>cg(mltgM!uS$=p_MDu*bp3U^y0ice%0tlOE-pKR&6&zy zb3tIWg#kOoNQU63iPW~iNgB^Q!Ol1EZcI%cC+HcmzY^6CMd9O!8jm&%aN$+}sq4z< zmrbWQ-{*JEby^$cGCSQR7DuXGkPx-DN=ZHX9gNDebn zUv9KB=HyQIvVpPQ8vOF?-RtncrcBOmMnIlOGsmZM250~@AEhNk{^X@x8+01Qs9%~Y zhPe__;{}K_CzV;y=k=TbXtddSBVWq7b2p`^L007B2bn+KGTAqMWY{S1?{l{7D#Fq- z{lbv_ow5crrFqX6%wrRmlf6$Y9Ol1sz(js>sB5J|Wjf#{kBH{!#=te?MvcQ;lVi*t zq=Re7ap@x-pH%-6{VMn0cLBtz2*`equv{5_WC(tX34Zs zrDG<&A7gfxnlAxm65&-2RsQAQ9{&G}kcANufFSdQB)OBC=ch3IYS+v*B_Xxrhu|N#Nrnao094Esf7^Z$;zxzd0e--hB3ZyT zyu+enDjUUI1jz23fJClzyg!WB=@UhQ?sMgWG=T=v8Iq%#5j>EqUA&u^{R$5xWZkD@ zxPLWR{#<*QEB(}FMzR+iFso`E;f=X}ip|_^X6hpH0Z7i&x%Tm+pPSy@)oZ_+*An>+ zPDRlf&(no7B6qt$;IMoWX9A1VfD>zIJjm{`Q|CY@g>hKflkM zO!PRbT+44eTxj&LGM@+vJ`&LPjr!LH(SE2j4)9v<6fp2MfI&6XP+)1k`lpj3@gv&M ztBBI;;I9GAj21t@tSWF3{~8IO?MF(9jrBkSU_4zue*VWp@~BBJvELP^|Ly&tcH;f^ z6mX`tTvmg*I+s7Q>o(M1pjZ1HQMUz(INc#I?@EH;-C<^jX&N|maJAz-JpR4>|7)peoJCnzq6qck9?LHDSUhI<9Tdtm~soBKUGPy`olc{ z7~@R4l6poj=ws-gy_sJ4oY1ln za6frQ^}cgFN*8xOxoVyLd=EGe+~WIjRZVm2HNa_>eT~T>)j=j?Vn#z8n^;Ju?YwjIM~1KQpI`$`BL+LxP^(u zzh&s=gUaC2S3RkzzAVvpAa8k*De?EDh2dJi31Hpp%VVH4Z}xn~oj=@DXU^uZ)*4C* z%o_jhsoDcz{;s|6Q2MhydB#TumkJL{yqW$xE0B2&z}K8QZR6lSeZ%%cK=}yiE&Zwz zd7zYsD==8;Yyay{IUpI}{k5nlxc^?lpTz*E^1lW$7XK@t_|IF%$Lt-*6T{%HfUiNK&SI#tFn|MD4M>~UoyWD7ppe~Rm(E`c||o+WbpwzujDfRZH^ z_K~Cf!-@QS8N5K?N6~M_{GSy-e>My+d|g}A|Mu}BonR6jB$|FFB0-V+u2AArhxEMS zPQJ!6<^Pt-W_$t~)8^?ML;8C>!a(`N_l*XSQ=kONG?2pXEO@ftDfJ`KWugcdEUiJY z`=x^Du_q>|-5Y>>EW<^Qz?T?-GCP^cg!1*S75jD;bHKRR8^TIHL>_Iw@ewP|bK*x5)hn=3g%a;0?v)dN>!2 zvjbqWr+(tkE$Njh1EGNVn8D(WsiP$C6E|Ff;+mk?hkX~o9-?Mj!^ih9-J%4IT^oAk zfI&~*$8OXj-G*|t`#`*SQFR=bg04&+McCSg2w`4*NXpQAxa^j_undw(CLpf`$w&54 z{Od&eSmL%(T%r>Gxn=c{LrILXMP5P@mFd%|8-AS14G=+rXdf58Bxp0t9I zfY@36EQ&8}9jylNvhFB|O-%m|Z?mL}D*F`pI}ALx#5LJMGl4;C998qXb~P49cF8C3 z_5c<*MC*;LFn1}AUSGq}@*tm{ZtJ`P87(OSDdLgzc$>KL#5U7&0DVQs$FTo(pUcuT zoK_xw@5xIokd_D|Jyk`eN#Ax61XT1%83;bKtKIixtdqN%aJe+VlyMI=g8XfcBWLaJ z$YdoL2GRj#%h@@j-X}Yh2qD+a7yN)IEPn3vJI$_#0*R$D1%WLw7knpHsN4RCc^-~b zD(!`?#O>@rm$NdgcRF|q)ExAxT>YhwKqbLTSR6^X&zTF4Zy(G>A8OQ|WXF;gUOeYU z_~U;2?H48d8$(C;de8q_jQdBj!Vq&lPp1m+@C<$4y>gpM{va{!lJ+HkVlr>Kiw zA0ICYy7#1M(9rwXxH+rErm~ZU&PXD zUk704p{`l`p71I3d%EXA7G&H@l^@gc4nDfp=|NehD2(kb8*z77@jN)Iy{4-2l?i+O z@l`oLvXbbvF{AjfIE@j=?Mdu63bOr@yb=`_P+oS$S!T$d zvVf7GkXg@r&dw`)hHJmQ>d<+v^c`6~UY9U9)1*?P49ls*mI~yPW4 zZ~XGY0#q&P>CE0E`Ms|`0NXzAvJGCO>&F6Bu88h5kFVLpuy)7@ z92UP1swr$`1oe}|Rkm<}s;^Q>3)sSs#RbN|6t8`GcS7vIS&IXHHJD{455StMq|@3d z_nN2lu}5dP-VJyo=&@e{EG^Sq%r$V+Y#xGdO6fS5LU7c-3?viZbtDO*ZtP8XY*eTX z(FEnJ=quBz_ZHj|*HG_o%R@}?1z^-`(`*NZs$8NhoANUG8MC@T__ zM^_@##e&y_wx6JYyHoTF+dT zDy6#bFArUCEUm-xIvW|JXi37ku1LJ=d#>~M?!`J;?5M4>TF3eQ@s9m57M>pdtoDE( zQ9?ErCc(+K$Zc{M`_4yCiA07kW_>z>a2@i*?9NO+S*q z7TKa#0R%``qWf}|ojItEgTEsX;x$0yVH%eKt$a&XKfB=5lJ2qR6R2j1YbH?>Ay(~g zdO5&#_!pgKJMBo*zT|nJwqcpp;@3>>qGybSYiQ7IZN``c7lA$42P@TZw}Wk0gFLzX z5=uQEY*Vd<_6FIvi-4$E=U#~07gSA_%`q6l0{(tx)&wTy4VGWjpqffk;?9hoY8rW~ zZANl`<9*J|Xn6JG+*e+d&~5S3!1ev!9NW?2u38-%_$Yyc7u+hJa$3Qt0I#z}$A-D& zjPk3koT>^M{T*14b|@&HL66~mg{&U9K!y?#2tk)F!+ANr`DGQ&#g&4}*9>dC+0$Iy z$KJ)cr(cj`DK!2ch*>B!KA^2EhR&jxd~v&t_=%MSFuDKSa7qcA!<|$jf-MV%bi}KVijLP{%p%asx2#tlIDfK-Ut`A?Wwg*?_0W3lEHl zavOIVlK*6!`O1-x2kf>g>CeiskIiJr7(|byPxSgKv+ zTC|exyf(_J6Ngc{-?P-0fu!_0Cir$pric+J&0l;GFovVxbXErQSWF`(YTyBW(;1Yz zwb*rjz(Vd>1K{4uH=WQ~)Vtgn`=oh!AZMXd6)c8c0zm`C>4Gg{9|(v%(-&Q7)+k+e zyXruGnj}L}gL3Hzblev2>t7(hZ06i6O$T!chkF5nXc(93TLxuIY{;EX{+Tti#M7|~ z2XZ>{8BoJB-#e3EwcTb{OT)5w*HL(pjLT+h*dJOs(Hqzx4HFzhTwspG(XF6lZXBL^ zW%)#kk?(S)CTFn9%5u#hG+JKS-Bg^F$OA?`C`g{J!zwMXdn|iU1suz9c4XAH;Lzbb z6~#-B{9V2Ny)PgA=#pGhjJ5g(fn(a(#!p_;Ug-#3pL!o z&tKXH6KTIXo7z3C>E<9yr9T=2RvT?cXr<}74>c!Yk||}6Z)RFg8=1YRBg?+z^Sst2 zr%CyV?Ei}V=43@`1G|NZrQcP&l)r`^+JGEy*y#IRJo>*%cbS=Rz>2>Qq;6$Mvoq}( zfqO0u%!c*n8R%?1GKB68;EH~<7;#hs1i%b*{O#IgFN_8Hokh8FmMG0tD8M5|rQu&V zNrFNRDBKqxB6oXb_4jZ|gx>cucXk{ErrzIP1qVJ}quSjCreea9)@!gfNC3~|w&k_r zK(SRug1wA!Ls1(o1>g%Bx5_n5ebunL zeX=cv!Uv#)vvGemT(PNx`pX(vR$K1UczgtetB&}+eT|r7p@SdiaY$-Bd;aT3PwCzy z+ky=M%O=E?cASjdH}w_@OjajrYpVg&twA1O!Hdc$1l>&|kh>&?IFOnumOe9hI0At7 z%n7G&esx&DDZ%qeiO0hLqAa9N9%379`|k1yqae}RQemD$+3HM)cA#~+v3@mg797sM zmrw5}w6{$Yt!JE(=p2_UbEH3b_!1m}bkBFRc?{bhd{1*OeUj3w+E(CAYd9V1p&!|_ zM&BdS<)pp0F4EI4WA5u<=>_ey9BcNhyAKruD72zXHlfRh2cVeYLwyA}CF;o`Gzs#{ z`O@?XY?Ye3o|>WGSbIiD1naAgvalT)C;KR-9Yp@|MLh4)NXvl|Fw`2uYTic{`T9=d zFL)+s1d$^;@?mE39eGXLVNu1mVaI2gwF{V{8SRMHqQUR}$%Va%df=PASm z^^Od$>REJ{t!~`+dDNk;pyqss;R|r~CO*>9yQ_POd#=9A`kh9{T5ey+-gufPoA$N` z^;@>!{JzR9N-a|+k)snVC9M#Q+`MdBvnzb~P3EYL%s1b2lgZf@6%R57`j3RZ6 z66_XBmegyt_oambpzd-I>0+x%tEXSsSTG2<=xPos(>9)T^47DK z|9Bf=Xs@T1_-S;h-m^~VWT#(l2pzbOa@h`{BvDyad?V(agPLP%)*xFf^R@bHHhP*Y@ z^5S`_oOOj#RgtE;y)~BJAeA+OON%+P1iJFR{vkptP%b5Ot(hR&iGn7DhWPk$x}q-2 ztpZTVny;M?GDdNn7GX}y{jF=0>GGtcT$T~K_`+>`lf4b~@Pr)pHUnPC+5}dLd56gZ zA*UJ|vh0r`yoafd11E<}DZJtDvon)Hx!D_(d~X@mTi-_6x6;!_+99~y4y}>?9Kiif z%EI*=T!tOu;^02hRf@7u(;VzW3uJ03u5vy95M(%~8a7`~dPdd8?hBLJd-U+neV2M{ z)stJUl-U0goaxs(jSo;!H2}8&7!!_k)qXF|NOJ3;_1=^gs4gASsUL-u`>d6<<15cL z>)0JW0ohQo5b`ZbZYXa(3HKG9;?*wmKz`8ippM!ROsYER}981lS$(d`|#XVYBx>hyAUDmW5q_2OF0+k4M z@0f@^@S?tar}Y!zLr|H=g>os-w+;_Ft-d@#hR%;iZ0H`lYNvtg5LWwd^ZV)ZvnmQb zX{!n9{g|CO8O0Jen!d(eLhdrm2!oVQf5>^aKUwb_(y6XRTZ8t&9Sml0fFQOm4dTu( zF1`yvX&NdbSh&Yq6h3OfqOWeqIX7cK++q_RgBbq;<9CVkcHi3JeE|-4b&ad^om;5m z)3mARd;*=&>2m(Q$yW_-j@a+sG+&)j%oq*xPEB4;8eyx%AH6vAj5jI&Sg&&nTq|r$ z>HT=1%(6DiN$BWJc@Apb{aek^vhGUZvBsc4B|#E&N?o-L5-H0j&^M@OQd$m4znB=V zX-G=haf*lKu2giiT_Rgag48zB_Bai3Pwe4BK8TCp;$T6|ssNofCJ1AjBwIjsQygPp zj1&u%aXP8r?q#wa&5Slg_}*C2-3aWokFPtlt&~PBj_hnW6cwV5o+-U}{BH|^9q$0HWjZ&-%S=P$>OJKgnMtmwmLlak{J@}N;3 z(XK0t95ZV{b{oTr4uQSAV8zjg7&|1K^}yU}=jDpTxotcDao2UDc)6h% zzLvQfmR+te$1^8_w4EpV4}vkVr7t^(*DzK`-aE&}n!5}bZQZOG;iChGK8HHt_T7Xb z{#P4Mp&fhm=T7Xs;eSj8B3W9m2!tj(5x~q)t>JsV6 z?U$%cQyEn!zG@`&HpNsFhEh{b<4kT-Mb%5W1Q-mYh+d9%R=rh8WYit_BA~l$B_CNS zgsUH{QF$Jx63m#FcOp%pdbN=w&)h@6GAFf&MmSq4Km0rGEeQs7Tv=%<>d;MQcy}fn zYDP#J^^GtC&&I!wF>tJDD_vER_o{UH zZ^69`vrt6&DBLxsi(_$=7u08r-(0dU!VDDiMOgREHGra-LFb7IDXm42QeHB2-!e`e zriVJF1WcG=XsS%h))3>5#U-{)1b*L7qhL?kjfm!FGkTwy!@9Ok{b16duDkTux8eD_ z0`#Q(kbZlo@WSlDkxWVDOcE7G@x}m2y4Y3Etw^4PgfCEj>!FEY0c-EcAdfqawVU@5 z6LT%^tWkNVLC-xWk3;yDik#Z_*1)|f%~<-;ekY!>I6Ip#g-UT_5qjRF@fjIJ?fe>z zka}96pUbIvIdoImlG6lXva8JGeuAz!$R&EFsthSubCG4nKP;O;0y6qffwQfI|09>jg+p+36^h; zQPc3JlE}X=J{EU8o*=fRM3pofEI0REHKfS#kUjhWCS-4vh=+iGf3g$MuEs+WsKtHko%Bq$V_Q!mkel z_dzTqtWPNlVIPlinQ0q$aO*y{NGcC550`50e!_0yB})j{B9T(vt8VKIgO1gu&x~+eZqPR+|48}p*sa7I%n>DyWI#Nlc(=jMZpA`*BDYzAsY~a|j zGTN%Qaq@)mSG$%Q#Yf#M*b#JIdvSRk;Zd}1|I}tJVd}V%qw!NKIh##|Y5P4*?&OelFTcYYZLFCU7K~~?{#lIp(C+q8USmjdhrS-pafIeea z7tvB-1nO{qgS#?*BPCr>i7a3{)3;yT_)YTrFke|DaX(3>_IWByeB8BHI%u8HI1!1E z7C(Lwkq3@8u7gZS2$gSc(z!g4inZe#-rY^Mq%Zm|*^JjWh*6*>TLT4k+)#|8Caga> ztgP;_^Xf2foDRKimOQv=<)fd0%2jYf3Vm?ooGZ1Y3~hMHBYzd>dm6cWaTJL~@Q;20 z>$DY8Gz@&hsp5gTAPugNDH9#;_+98mc7rlZI$sw(spdl{PGr>E?zeQk_i$wl_T;)- zRLNL@p>Q;n7KGY_%XXqlYe%UtqL8kLBROpCsNO@yK?yU&-k6-mCC=%Xs5E&-WNzh-M7&S+PjPjbke~cS<(w|Vp-q^mju;&c1F(MNoO4_R$40WlLbZ& zgV$x1zGDZ%Zxk@eE54c+}c?gr`Z?(WWa_Wi_pf9E;p-2aFuti9H(nQN}O;`1G!2*`4;Yh?5D zxhDMnK=tD>Pt0#XXKc*LW$-#XQA1S$TQ-?gd)H+k!NYx+>FgXeEIUT0)qE3VWN9gt zGph!T<^z6)t={N5O7t}3C-XDzKpO6G|NQtJ6C)8gG)VmvVG#@2CF~8ye09M+vBd8$ zO}C8!cWE`26(YR69R5@3q}#`L0siE>SRveV9E64WYob#rmCVdn7EZkzD9acAD z7S)XYmCBsru5QOh_j{8fP24pDV!fm66M|t;#{Ca1=T(O~M2Sb`tw`(^JqXD>Eef`w zW52~!1r+n5vCG3rHTMZbh|DMKh%^~7@ySiI17BWCx>@jkfXyQ9D!|W>kML!`D=|sL zCij!XAi|N5T0bxOBBl_hW8GV~S6;A>?iVT9f49A<+o=FAT%%xtGcBPQ|3f-t2!q1igIw{29KIBx zS5oE>IUl(`8O9pq*`HF|IXAGvd)_7{wo=SA*uc!w*!Yo3eMsyRmMT9T`83gDMYv@R zfQQ8&O3~E{gzSF({Pxj!`2M7>u4vJ}&>TnPXspCYDXg%`C(X6(a{Su&spTzoPa=~M zHhEZCqbj}+NAFuRM$+kEQ(sblGdA7q zALQTGq;bLtRS+3A98DVvA`%=Xc3$DGYWQLo>}EDAJ*ifBS-^9#S%E(38~=I6XWBiTtEL9Wii)^=;(gxPgWE#`o3l3e;kq=3 zv$^vO`mV(7`yF_WMaM4cutHM5iK3=lg0hRA$Kc)BAB&g^Hy2I)4Yub=yk{v5mT8p* z3H6-U_aT;Jf`>T#SrnzaH?cSCoGkJ3LgOB@F2}kC7NhQLyEE5IPSu;bZv8sGsjqw# zEvbofe-GrP#_haH^Ub@wzf`y^^GTLZ2W`V?-?eQ51nm}9wBL)a)pA6*=c8|na3Qn zhQgXTDh1Rt$Jnm){u9}$+2MGXTN_d5p@XiR2S4Ed!^aKqbpMj(L3{J~h-4L}{529g z{HD92Fx9;&X}A8XrQmKI$vk5od+z3g?vDrFxyMoO_0&hNQ;!-4$$t`MVBo@@Z&mC( zZhN?p5mVasC=>W|f)WxbKMP!cm2FnvLpg;LRG#du=v0Yq3n?tLHtam0xYunbK@t8L z*WX8SJRdtdR`N|SmC|CaDz>;trV{s(=Tj|w?J7)&GA^jUr;dbG6FHd$S(}}xvTOOc zUCsq+OrFjUnBfnsGz~vOC$V$nwrwEKug$fBbSE&8ifW(jf_Bk-Iwl3v@2M#gzk0I_ z*&aIr@VaQMDL#U9Ql6}=IEm_AJsc&AY$#b7!*Abs##`@iGaw~JBF6aQwh=T<%3Ov^ zhHd4!@GC`%s#{Q`>lKoaUW=!LZ!p-z)j5)uN)vfvG>Oe09f@n(U_Ys7i}yyF^jpuv?cNSKQ`N~}myg1S zrn(2et*r7MNtB!w3i#*P(K=2lC5Yn7d=w`V?ub&|Yh%>35pwZ#f8_QeSsfQ%XLq`AT*G@pf4N#NH%3X&ZSHnS$HX)-*sc>{g z>+BF8E)3%?@d>`v(Wb*b^5eYxeD=wa>PY*fH$G?UBb_fg+U8TSvk*v%O(gN$JwDPF zR7Femx&*UH=;<7da7n->?65^Lvue*$8(q9oIO{3y(=8q&|j zmTs3*dgwWuKuGT_OOw_1Idl0`Tt5u2p!GUykqLj--`wge#xOZ1&5Rib#r+Kd?(=pY zU0R93%F+a^d(OlVu6RZf@7W7K#VgwLT7SISayMJMN1w0DA%gw(+h0IJj33q>Wh?KRGi(w@5x6DQEy~jK8 zYI9tXk#XL%t`5lDEvFlki^A+;R@fZG%)B;71hUCk_!}$L^UndK3&_qMx{eA7WLJ*IK!k7M0oeK~!`Ol%qHD4cf zg8%|=MKKV!%gp`?tyE;Fr1m!pV+{QK3&a2|(n-(L?259IG|mDVB&lc}>Vu*ehlhjw4G zhT`E=@UJB&$+FK0#v4hvViqW~if}2#>61v54S`#@_8~2plH8M`U4|&dF%ujVZO(yq z2uo<)N1nrv-`n4kwx7o>7&9H7x zEMmhq!94ZiFdwTP@Q%)R3(WN5I;8d6yQwu|Qz>cQ$C5^7L^JzGkxJ3f#`e&qbvRL%x5?KiI3e2SCT^jO>K)*-$=6KRpt0s z>ih(^yHLj5$S9iI_d?LkI0gO>LxwGwg=OmN3wZF;)YaH=`!rF`2Mss-7A5$#J-Mzj6P3#m2Eb z|A!GNRnhGxjL|(ufMkNgrq?9Ny-{M3A}HtLw^IyxAC3K&CNGg;Ct-zb;5SJijq=r3 zXCYcepYaKQRF9UYtj%?daAy&St!S}G#fAC{Hd-tT%-J4%zn zeMpG(zBJtHNE&jTzXZa?KX;E6X7z>zX#E$cO{WD{wTzw!Qi_>=#_5-%2z0>9RF{>D zOtcG%Ja1%JV^_qCI)dQ(8@ngV^X*|bnDdApCY+7%y9`JKqh(x_5<7AwgULlrnl(r< zTH7SY5t6!}=%jTl_-tfwaZ}qVh&Namb&d{q#b-`ntkE|$+FK!n#FaJdWmkiNWI%rQ z_pl$pyCEJs9TTHO8Ey9&^p|hFJVjX~>mr6^2k2~78QuJwHfz$kP32g^5qhVkaeR12 zuAp)2JxthEzHW4X-TAopyRl8MdZ7+*-p;($|d>>A^9m;%iz2Ls}--EVAMR0RD-KnGG? znDT1CT0;8D=!h98HJX<`Re9dg2GY8%m2+RERFgq??TZmVu?V|zdkr83Re!dQo4mU| zT`jM1pX;F|HglI8&9iIJ3*AeLn7mom#n@}b?lI#u86boLC04Xq6RFq{2geae+|d0f z^J?$kKP~2*AuFj_V-N(|{p^Y6TeU%7o%_Hz6F0?2F1|E<)=Q$HT1v9vIGT!L8JOUlXoC(lz5fL z0ZmvY)avZ&9f#>sQ{k?>Z0)YbEIoI7LDs&*y^ycYHmm1DG-v15-W$ti(rFL;l?DKt z*#l+Kj<*7!@Qr3L_vospb`PFLgb8Vs!S$Llg66F#B}=PVw<5uFVTtv&5JAUrguSx( z{hHfgD|H(9+d1nz&5O`JxAJ7+RBCHU?Huf89c;u7Z`%Pb0sq>H##vR8$Don$MuH3~ znGh?Et%+Mw8%vjx&+f;;uW;+r(K8)T;=OZ-rFb{a#J}BK%MQatI$U`?#v380Z7!x# ziF`aGRg zg$l~g<1&9lFh`ayF6yFT(zKloJj@_>_4!mZb%y`wciY$wA=|D?XBij3MngcWPMsQj zahhiPbz5*V{Wg~B!=#lGr?mQ@O&E42YQwLHB*t+Rl^!wEuIzb;-CG79(#SRP49?5R z>XC*8Jdt^2xKhA&G>jGtMWUcvaJtV34vG%4N7<=8>KOD0pq9CPu26e%w(_&%EZbsx zzt!I3{-?dpbKEb)Fpr}^ zlk3fhlvE=`U=g#J(f5abVw(I7F`h(BTiV<0+Gsl~vky5*5Q*B(lbSEc_CZ(I6cC@y z*KU+ImB7#u<4jc@k6bJ$)Slg^`qt7K@gG*B)_F&6O#z(TZyJ+@eWmED;6n zAlRZjC>(g2FOnH%sB4_SQ7O$e)%onf97|+MyJSkhJ@9N(d};{vS!-GzB#F$ZOeBA~ zbM8I6drns4@`r2oH1cc#B?NT*{I^g%(5;sjc%j7TGomLV$~&g;h8TBaZUp%sg*BgI zNmCxL_a3*S_tP~7_Rmb{h>bBjPH@mbVVV}z&vjPw3zl6{e*f8aUq}KxOwspNU&Cl?g0cE_4=rE87-XC+#qn^VFp?DM2D?kIz2%G% z1clxc3cn0ez|2r2qua?$RnOgutsRl1iw%j&y$ma4;m;c2&Z3^TbosDgeX!0zOiQ!_ z(7D#aNq8BWRtpyV$R4kfi2pE!zg%7qus(>58l|9Ox^a5`=h9xJ$krl*n&Ol~;jMrl zqyO1i)2lM=o=;80qIWxxahP~c550)Ew;wT2?tgA4u=O+KUxu|FYonDha4vGqtwl9# zsaXiW7oiTM_a}ekJ#g>Nh>`t_uj)+Pm`XlOhdu6g8`dZs`*p>YWa=J`{}tPXwD21i zrE<=oWwOSfE6coCSyNBjl4wnlXl8Fj6av-?Uy9yoCs6z%1F_mV$khn%ex zH5Ka&D;90p7X~!rPi)%Cj)e%yCR?dL7Hldp)1B8Rsq2OE-N{GzYj1Cl2CNzL=V515 zqIV!K>BU(vd1eZ>vFnVmM-4(Wv(3}VahGrN-ySoY*4KD*4~l$ z`{=>R2#P^&FD*gq<%NQspz!h@>mj_5%v)#Cd1sITgRV;kJq?S0qW%88I=%Q1e}w&xMmu+9jP#E} zWv5*EO)dYgYPW7b&uG;1Ag35Co=GRu03W^fHc(5W_L7_7{xdnZq^Jhyswn{Nq*k^f zpw7r9BVt5nO0iZy!#?T-SaSS$<3*qx+vaBay}ysnvymqz@vK~Pn_$1J(g=+SQzpL) zBoSQ5I6TJ~cjNs7shay5peYmLf-9PHwtrtGD5tS#4B+hGknIp(igD>ik^%7%okOWU#p34e#1?Z?bc>BC9eFzbv;T0KLq0>?m7dD@XR zP+hZM$RZMQ2e!t2z)Tr4rV9WJ0CeRuDW;)n)bD7|zetYf?bj~aE#^c}wnmEZC4gpg zn)ex!FD>PEuLn3mR_U;2WDd|2kZl&Xi01(=h&@5uWjlk?uQ+{WonNF(*?$kTlTCq+ z`vV7sx1+p=^pY}hV?G{MKQ>d)yHZL(;W?ixsOr{2vJ|;eUK0@!9CHKp)>fQE83rmE z#uC|}s6Lx_0i@cxMTFfzRd!hB-cGA>Z&Bk-ZpCK>7oL{DM5C!CGFX@ATf+md%4O0S8M><5FKxhzY#qy< z)DmnL*$MYNH;P!es-Z481)S<>^(>OjI&qAOdZBI3n^p7i)SN`y&gYG_V_%qP3^CDk zolFDyA6H&Z4!+8=li3?2Ih72p9<#W&*Ct?+GBw(-m?-X_v3!}~`i1b1LIs&bMbtTo zU9@?0fKzrd2E@!jgm=#7wm^$yo5WpUS9Ex+ev9gzJgg4&OH;x7J!jd$JS{u~v0CM}WC z4k1RP=4lb@=c&q@VSd<_GOUxG!{>ajT^fI2px+B+^e+a;gYCv~M$bH2MP-!+RxiX> z&aX&meBD;IyeT;uFT9P~>S^IF&b^!FnHZ88L6q)Be@J@X;66w>O^cCuEGw>=)dipC zE^HdjG3}+5ugm+_KI|aR4@%={PYmuyF?!~*ZsYfEHZqK{D~{NBAC{4qFb6utlxst% z{Cxh}*?(f9ofBS~Mo75?>w2v2Lvvp=Gz0d4Z{*(dT&(6YpH#;R8knz?qB^Asqqw1g z%{;Id!Be5N{!`u<5$&{fo6Nn-dwUh0bMa?WY5uCwy^VvRK=a7z;_wV|F~@!L^ryC4 zS;QeIT`vX32$Lj@BkFuJ9;7NjQb-B)$6tWKe|hdun?X(Ob-C!Vh<#rS8Yd2t4L>kQ zeaPSX=A<^<{tE4%wdW{^TOI82vXW+-iKV<2&NhSoAKq&~X;ZV_^+&3kv0s_c!qjcv*johqKhId2y} zo8fwSkm<2)bhb52d90X0zoHlOaCbhb3tA5XMwBu`!cDbF^WZpeL<*JQ>WZP|fi?3L z=BiZ9xyc|-z$YM30Fpf5_|~{7X$ye;5zP^Y5VB+0R)9I%zLwgm->Ts1T6$@XK zB=TB-_7~F=F^;pAuKAl30!AsnuW$9&=q^sSN^BxBow7c?qX~Wt{;>w|;Y5eK6hEP- z*5?v^!;Kj@8A=x?!5c-Zh*B{wJa|BP<4QSp*0Q072(tE0uZn{4HrrgUow4bAv;fYr z0^vw6C{NVmXWU-R6+V`y(ZI=&U2ms`KiE*9owJY%b5g^daIX&|Zw6lft=tWxsdNPs zurv2PF2UjH%;&0c)ma>|Js@gtSv7a9TQ!aRecSR}dZ_FTe`(r5l@w!B3%lUsUbw~5 zFIT0J8W#oA0?NGEsQ8L9pkc-?nKsiSD_A4dgAQH(5F-pU>qPiAtB7>FB+2meu)c zOqY-?<+HNrWO4ODzYDaqcpCeCx?x+%?G;YTNlHm!NCZi0`yqvtJE-g@$ats1&EQX8 zb1WE2AvMPm5TuilMIWGP6to-3LjsTy<%&! zt@z!%ySObeh{=vBvT|e+So+|$>f!K*0u;|HOk5UTWc=Gz_-9|C&^0=u65}nO>pKq^ z82kc>5AT#Q+|GoXbQD0i4@V@<=AYifxbHumhg-h_ap2G)J{Jjx;+Oz%Da;8SvlZJ} zD&yVvXDYn@F6oL2V7C02cbK)T1Y2V5aa#zK6oqksL_-Yi0n1cQr45@VH>1VW&io3X z4e_L&IX{Nm%O5l&8c!ald9kSTa_LZ#t#Ey%k+5NDN#DbTen#g>k;hGKaS+GG_7YVt zPfJ0h_!h|8vj+3Q04|mEJLweZC%R4{F$E24Sff&S5&2jJayUAhbL&Q>>A_R0dCsQX zLcPO^+ylGJ+hkroC*6C3OO&0}B)o}%UvGp7J;kp92rRa~o=+tWhIyoN<>nG!qzG%I;Mm6ztGD);o3DxJrdda(l$pZ4noZh7H4MeA#gN{oCbuMRH z7{j6ahMggen+dk49Vo?Q23TEURin4?uX|qBMAc;aL7U84Eb@`h{?wAJc7FOPul}aXh$yQbU~X#r0Y^=2#Oi5TsCPW#(`7|?w30yy1yGBZAy4d3U z37_v`J$+WH|Cq!s-dS0-OEAP_vd?qdv`*`;WIFumz>BLY7^P?4)2_N?VTco}n_Yo% zUh7tfcBz%wO_sE;I#aT}EN^x|K(;a=({Y`z)p%Hz8$gL197}f!UdP3A2r~M*$ogQR zMRsg=b&}Vx(emo2>>r4Wz5TZH{mJ$>pY5coNB3?@r|PL%Y}m{b9TKZ2lR$^(I?ME- z?OO%Y^rZlX8=A$!P0mv!8>;o2)%)ExHxD(V(O6A9{hAO8l(NNll`X;oWlNQkcYF0w z3dvMcmq`$l{;ESp{kiOS6Gk;hi*HV3O#~0gA8iqD;jj&fUTbLikKVhH?Pnkn8=ou~ z?cwKo(xxC~!~VZtI?`YmeeTdrlAJSdZ3rmtq*~fAakc7{;FX_z6vB~*@A(evHccUj zM4Ty*)`3DChO4|prRe6b6c-r5B>L6Xwtu0)b-r*oO^o0*reKUJ;equkncGoOdUG3S zw}@-`j!cyMtr*V$q%e!5lLa7zYi$l3Ep0@+4%HW*qqhL4==*AG$~%&Tv^^y`ej_M^ zRYe=d>G|BB>lcJv@~9$F?)U~Iu)-cGAHHFae*Q*~O5(0DMJW^aQjp(_bt!34rQUu( zD&f@=1_@Up+-ky=Pt+`?BjlrXj^zZu2sKOeMIwmPldKl%YY1hL_|lna+CaC3h$`Aq z_;D!DI9dYslQ(`)o)e5ZMq@bHD)(XiXQ~Q7{hY0NTHAV|dZe+Y?t6e)t-T_@jgS+^ z>-d22NlyphCwK8|ww>$jcQ=GMQLiRj0PCjjWW870`QZsw@UIb}(xo?#Fdg#>GpPbX zG|gEgvB8|UPU0l7eK_Yp5qVGRS}g?L#rW$Yb28}G`AW0kEX$}?>{*EE?SUqxmqY-2 z`=@b<71t@|5BC-17i*je96Gt_?)O2&9;DASMpHlzvoZ#TscW{})Bu%4V6X7pkVq#d z+DyFGAP2Aa6%FpoCujG~{%W%kL@A5FbIR|{sCo9O8%^t4oXq)Xh||jPvQRY+Isd6{ByD8> z1(7aSl^7wh1Q!3kmmY?Y?CYOuOK*bsulr6n(qxjW9ky>%FYvg!m$!xm30}WX;eVfk zOOg${R((IAvoYpV8nJ$V3elud?e> zpK`f6{I{`T2FAvN)H;OqSijJo_I`*b3N!JPk+p_shUfJaGIBP9_CwrLByQaLsOe4K z=cPn&Nl91t?>7rOS@gqX+O&Ez^)ASUugabfkC6PLKqs8!w@LmPFQF=%&P!ETdQU;~ z(V!-4s9ll3rb*~5L171mX#$y;V7kNUH{mGzCbpr5DA&Uf;TMT!6_aVZK@ z;PD`$gE1sI6I`+8988u=f^|?eco?4c+c>zZwsWN6izyqyB;tNM(nh#)E1b2(mpnS_ ziaP>w?W128Bix5-hm#**2<|(O7h2F{llK=;P6!|UAi;Q3yDs11n{}PGKdKT%zfa(V zh|f7zWXPkFF4P@*+UwtIr{6A}COn;fhGwdi;PPbO*&#viiSfpM{FjiJE)ynZ1XN z-wM=tYyg&k-BJKHu*Kr>Ft+Iuk+0|3@U;QC67}vq$x`vhJfy@V>aLd?eX&@Pm^_F! z=ZA}nc3VR&rYL;RhY+mAtQ*rKqLzrCMm>E`8iTAw`>NMt_AEZV5WX}#MgUU#L?;|v zdW*MzxKPM#B)aKrnf2o7G>eCjoSQ1jo&7(i{Gj_h&m6PvcJLS7*W^x?|@!x5$^6<#zzUL4x!7YNV^ zC1(If=qiAEQEP;Es5a*7Ghs{DWTq)bBtwjs`5(YB7a%eD$l^t!8pBCHxgHVID8Bhl zlzcd6X?%CFEslJB5pb9J<7YN>f3Q7VPUYE9`;P^=d7nW=EPBdlU zwUfB>Vhc;YfUJDmvqe2KwQL1aV4;I2%3}kqwgZ(q2`Z(>Olp_jKr(o`#{A%aQovqj z6EIy>{gJ+%Yktn8@}nkD@q+EP?>A$MJ@XoRdv^h*@$*~lO6 z=7$aPYqy;AG)Gr&@{C4d*qS~+k=Nhr_M*(PO6)E%RBaCpJ*l2S`P0N-ZkBIOKYtq~ zTWS;Z19f7SW~g>=MI+&Q=w(&xbElXA=Ps8fO4upGz2hkNo6X{{Fji>?b-fSAA1X+F zNRqgzU@;y~{H0`~d%vdjbhE`Ly5k$uC=)LIJ~R^-gz>#&d4igUnI8FUDDsyh&tdCkT2%dLXJB<5;#$JMX8ecKji=rMPTiiY&WECjzpVa8vk<`xkIhH#sFX{U{x63G zy9pKs<0NfKZ^s3X6D+W$>onxd&nP@K*G5J}=*ZOY0akutuxQ(Za))&Hw^Ge-z068c zh*w@b4_vKlyM`c4dOdl9Bgd1Iz1q_k%Z4I&3~P<#TPzZQtIz?{B~DWZc;DIgEhsQF zLNtP5@RxTyB@@F>eUT>1Kge1~5OG>1Nc9rs@q0W1n#pPkgO}#I8trOV3zn0@62B=~ z^$Q-wuL1xaXS;3k-N=YnP*9N7LvQJKGLrgEaYWPGaESYj^)#LqEI+HL1m6DlylPz} z^6kmf7GV#MdV58|a8W*;bT*xt00iQ%eDgJ_j5dH?DCU*Ew>w6IA0>6!s9tI)u_mCO zc*_%eG%Btn(5KFvp9r>Hqfvf@CX%Jki^E?54b@xV{^;HN3poJvn6n#|aSzQt=j2lz zH7rca?lHAky8H1|**^Z8<4(KYZ-%85_I@5J484J$&D_-&Pq?pOnrY5=@L|$z8Ghj5 z3Rv6BSK!L_1$kpZ5Nh13?xmD@Ni1iK=QD8Yj3HU=p9Gt20;1@v4y5W*QQ0lz2j`~B z@@%C~&=AwD&65u@E9}Jsank+4{%cmYA5Mg!D$)Z>r9GSx{-^A9D4Md(i=nR-GC9&3 zAD=fj9878(i3ZaA`2FQ@cJ!|NW?UX4TeN+!tZ-8B#94Hp zRZeiY!>UNVz@qx&?>8bvXx!J9L}i!DzD>L(^K45h(YdpNNj-#0hb2@=EXf*2Ff{=W)o}k|+r%oVZRTWNu!iII@uo9dUyOmC z&up&EN>``LVdJJyM6dqCD%CjmrE$H9hH{nz*~0nd?}p$_#w$)$3dH+N9xC@Hxj{{R z4!8R8eb|SSLxrX}#|eqZxA}9k0-lJ?w{)&ezXs;fB%UVrT;HCn>pm`smK|LiSvh{$ zHJn}0q0__I#ZR-Sy|m;g3@t9XxrO1;b+`Z6UK3>y!p}N@bA2&;*cXFfTx}1tJt}9u zHF2+-)+M;NG+2y1mXqX|e@T}U=v>q(IlUV~wQz&G>sHs9x!1X^nzY`W$1q^EkQ$mW2YxY8<-D#7}jX0$G^z@m}+>6KO$cfWgOwy~fA9GS-! zoTChaQY=F6^(=4G)rfX{HNnl3=8$+j>&CUj0TQgE!z{e$g16N$Qb+1bl)Ko3`BYs> z_io{z7w!i6vASM3oYFx``qmnDtI~G=-=obxzgw4j0*}@^>9#ZdZ(Bo=>M7(ykHaUe zeG*D0hA%|10yaDr6oQAVpP^`Ddvi5xD<9S_VFSPAy?Vk0cZKm{Zd)8-8pMR*5* zkS8*p=u}y0D`Rn$hPuZ_)NqwvFAJ;aS>70x*$4p45eEq5`ll;rLOjJ^(|h7;;Xh9p zbn|&nl21O)BI~schrhBV;y+p3{1ZJ9#{Sz~>fLECjA&@%E6&bO(06;`#*W@?IfDl>g&)>-GJ` z@R>kg;*4#QX(Ohs(bbcvvNe5U2=@Put4j}<7**0BGKd_LP{^1hsZ z`3J3nDa(B+9|9Vz;nZ0;x4k(Yx7%59?brsD1k4wZz7rI0q2zOrn*Agj&(S6Y!aw~Vi;z+Tq|3^ivr|$u4 zI;Fjd?^(pkCZJpten~))$U;uQ-R(_hp1;BjJ#BsybaT8xp+-U#_#FWae*|IG*AYOB zGhV2}%}N{1%27#a7=FqygoQy~}J%-jEm%ieCoa{9a+S zWF&1z9wv7u#Cok8L#fFf0!kxywjZ~`onhVm;0tV$m;1UaKD5`N!dOs@bI^=*JZE8D z!c>zms?R~C3a`=E;;XBEgrPBbhnh3T2^d*6woiwUx70OABWe>?^yv zd*NX9)4AerIeKhFdFjn4nU;g)+66^O5hC!d&26Pl#5~6Tb?A}*SJ*|_%fCR#`Pv;a z*w}iww=>TF2ZGk;D0kBK;_>^v;*JO1h4Sun-oIiw(@LI`xp&k~G5p#U6dzN{>8$Ur zY@&@{eP)cAsVxs_$PX?m|7LCUanNaXp~5`+Pj9cSi1;HvctuVfbZabx^seVS>jMqs zBlOW9L%kk+0uxreJ+frRU4mt%b3+qgnd;ASW$LaA-!!as|8}HMm5|DT%@tC?eryQ*8Pxq6({PE`Ee$s+|Ba#|{C_wHfERlm|gkA+ZvCPZ}%|aa5Q3xUJ&9pYI>h zC2-P9S)oZ;((8z~tLYW6f&C|2=Phd&Nu1>1Lb0PfJfSzxt? zHTqm!{b6HcqZVUxbCbaRt-st8MWUyKnCa37V7eXKtB7h+YI6@{LBlInK7V-pqJ~Fh zT&FZE-OY^o&lB<6U*=F(1DW{GdPE^ zjLWF?na5#It&d-2Zv(1Ah0CO~_W{`cfE=9$mkwNV>x4BH-p;OCh-x>y^mBl8HspDJSvndU9w0g&{BcsA& z0f$h%_>&jG004Yo|DfCO+nz4Vnr?*DabM2a_JeZAfSRh($vePoTJ~H8-CZ-Fn4)nn z(KA@>f4+nw)CSA~=0ZLf`M>$q|KYOKlStG~YK2*`CUAv@hu2 zPHST05L__&OYRMS*7x%V|K_J{p$;*0*rkG~f3{9pd^*44}G`trO*xM?7NGtqSbbRQa(1Hj`ERpRG2PE&h} zA}HkGm%WfMyM0hcvzGMWQ4%sS|M%;mL;uT56dj$<0`5cAKYp%)^0kwiIay&7xbgq< zH<$39w6y8c?_vDor)?%aE4u2x0*0Z?OCmFA31^LebFq5<+VaWd0gxAjQe<1eJHZ0`);@!ak@a&9KN3 zXESg9dU^lX7%+oq*h}uOhR}%5FyKo1KQo_k)%+iz(`UJ|3vUGPBzrp*X1BEu?8Y1lpYjKt@pgzS4456v&TcZp}dRo_U-Mt1LAL z(&oC9j=G!A(X;uy%+QE4F+feJliU6ILNKV1 z<#@R262vWZ29utYLf_=#0<@)dKyjx)u+JbQNEnqneK_vONAT_qG|g@ya|)=`?*51I z@BPJyoefnU!=xDfz|3+foy_#?14#3@@mY|gUj;8CX^&k=Nl)XJm3>e?8pf*!J^Sa9 z9=_j7EZV3I((3_$gk{;yFGG4miB9l0Ye+d!2nifDS3{10B@`2|j^R<4VI$f=kU6qW z{h;&=O$iGK9R6iZ+_flgp_*@<;0JZHshreOx{by95YWJ~CYZG->t~`On7Rd-HT{Zl zmEM8OUrQjvz4^>cR7yyJmuFCIV{mmVs$D}tC z+V)`p$t=fm(^2EFbK2u{F!T!MJ4?#|)I$NwB`#kNu%h>M0WEDC zoJblU{>H|@AxejMf{QnRi)R|AGcSXdO2I$J(z~8T075@K64`-6g~xXXiX2x!9p_-- zE*MJs`lD8;>08m1{`v^&o&o`%_pK&J8R)Q_u3ZK47`~Sau1p;BcB6YUAhg1P%DwjM z*&!RSwtK{;_a}fY$cd|MH!SgtAj9LapE~0ys#2$&0Li6nWRUU6jB)O zm-U;l&aXf~bh5C}NFlQHxrRNmQ}HvL}GLwW}2B~&B! z8MDs_EYP8fY4o6OH6shD3oL%U1m}&|<8~}L?4FSU)?RunK#0>wM_*n0<^xKa+@XSC zMzh6$8Q1~H&e|NFYK-}+Sr{mUZyk<~KwVo1=yc}VVFGzK9gc$z&RMX+OIc#WVwnPp%D^zJLx*8N&6MWvhT*lmPKy3F zpY?d%rgAQ>Y5>k{2fNjdYTO5j>N^{Ne6;&|~1h`LN-#6NxT=Xz3pK zo`X)Hh_&<-w9i;;0qNyw_ZmL2$jIUNH)m>)%yT52`dtQ;G5=C65B zGP9G|i%ej+P3=d>pCM74R)OL6vY~g<;jaLx3$Pu}}XfNP{`#z|$m!Ul`f*h8FYkoph6h!rGhe zg5C3OSNE|eN^St@tKbN0GtC;bU(iTIim}Gf0(``D;4jlZ>ui|lCNPjGtb_O^8?N)A2z z08AJVmawUG`|8fU1~}0C8(>Fu36Wz%-Z0^E1D1GY@$td&drKP-d8P-Fl#P4DKsL^hb`?BQX?VIzWsUh2~eVU`&y&lThrV{RRSd)USxcSoiQ5Tfk!T=Fkzf|;U5xF z)1%_*5E{>zC`~I6y#M_G4z6XdaKJGyDiSJ4T(Vc%i#xz6dc=Hw&)IY>vz&yo55&Do zhoaBs?p{RXxEl;O#dx@%XaiY;6QAi#;E!QJ*YIz8hClV)qygB$`@oMRg8k+*&JyhM z?(giNnR<#k0$QH)Cx2mQ8mE}|#!&I@iFH8VBa62zu6_hQT`-hm8IQpP0b;NF>WC~& zeKuggfLd3NF^BYI*3c&;YKN_%-&YRP?zvR}t%nn(C|iaO5GO3WlR#v4@74Be2JL## z*kQEZAvlun3p!CD=_!W55%K}5UIxHHVO4jIX4GDR+6HywSPEXo2dH&v2b*{zPNK)d zbqOG!tBuR1OXF2=e}HUY>@`n-##2Br%r`5)*}IywP9HT_u5#L_Z!zen`O<>9s21nRH7QQfjW zGzl>zrz0-Gz{Ao4Hob?4mx1cY9uBo!PBBq309*PUIXWX2k6`9RiUG(qpZr|^ ztu)_>2r7v(Zl$gM@>>8%w=cJU>lNA}*c3lc7HUsZf<3{pq!o$Ia8ok6iP6>TJuDop ztk6;MinsLl=YPAhF};k5_~wkFwe_1lpHRpoo#}yg$`v58*H0^RcpmOPa+b(|$48%Z zU*we8`gU~*CU?EM|4)u6 zKRq6wG};zWuA2kPak^|I><`!>bQA1GBy513mXQ%uzKy4?2FgUv~FDr-<;ef-W zlS}#@ZATT zQz%E>s!QgAI)jPg`U3?{Tn%x|XgX)t?`r4FN>4dWvbHv%GV2{6=#{nBg{ z(6R~?jvB75nARY0^Rg&}O z#xy!8BfHJN0*v@TY~yl_*Yes>Q_P;^BK)_eSV8E>dL=s=nQ@>SFBLdpE*oHvctx`} z2{dvnGMhoA9J_fBbe{z7fYonx6nHeT5Mm-=n=DkA75*IT(_NWRWAH9|Eju^0ZX+V|X{2&zR z85%Jmjc>T|6QD+I@LSgQIdSGg#%tplRIs$gIWlH}<4D?%8Y=*OZav{k@yU#7s!{p~ zQ>ikd%;KCN7_S{nbiVw;<1qqgKI(a7BqpnInZL7ykz&c(M!v~ zmB)s9EE9!>p;67U$=3`&eW8cD_n(*}_fsxk30uNSE|7*&@Zgnpq&$h#{%@&W~aT9 zuF}%C=F7lk!-9&dKs9)e!9+AN!Y=(eF!-CG5brlKN9 zm0p9QSZFG}3rO!E9fE*>ihzpJt8{77JCUwH2)#q-p%*CuLXtaIJm-7v`R+dEz5n<; zFM`QFh`1{8#GZQEUwV=x)hkbrw zqx(K~?n}r3r=*Sr*-+(n1-SPgx*$8i*y)_3dMe9{^o}m;nKa^3Kq)KS$3!_Ro6bRg zPXvywM)!;N>zPR@hCZUmX4_Byv@9g#_TDT_&ni2e@-{NaKbZ4-CE#hxJqCR>&ya>q zHsW5Z4Mp=EKbE7%zdi!K=xdOw`JW{k#1l%Cr&yJlcVk$jxD7Huwhzn4{(`cr?piNw zt~W?&!ayN)Fvl6(RD7S3kiH6nh_}ECmK!91A%SM#!Zgf9YQ58iLeNQs?M#Vc>Uq>0 z30Ul+U{aFi;EgSuu1Z&99N-QK#TMff0SD{%wN3hI*BnZAo zn^S^WFDh^s-mP`s*QQX-TF=f*{OVSh;(snp>Tsj6xOcG#Fy3{<(&cE3 z<^DXFdbk87y%K};cE-Vf`F#b|*4(#7eW%G9f?miF1pAtuq%+^u6*cRKw%M(R5(TLE z84UtJ$Tq2ty%Xw1gH$lGU!|Bm!%J~>7rc236h3Po>n-Y};{GOEV%2YDW#2oy(;CXe zz}dAAG~85>MLE>Oa;7);6F3{*Wj;m^lq&Ugngxy=Rfp=orpLZYlCe{=$rLA!({a#x zmY$@u-WVS+1@9xI00i4iocrQEa%TOMVj$$mGD}n}Pfvo;Y2h5`2A3wNY`Wc(Yp}Va z>XOn%qTm_i8#DiUc%B@;WRtb2PO(N(hff-vek}+6+CmMGBR}^-ko=>kkH@-fd;?X$ zULh0cisxHxi?wy9TN>G+Fe|wYylIa2OP6r6?)jx`F)i!BO9U@eb(al2eTsEp=;bmm zxtu)f4xp5aSxx^6WDnd*rCn z4Y0(kJyuc$yp#iMxtB?PE7>g{f#MX~yF0-UBVtQ|fiGrd4JBLQKb|2Ayg221waX-f z7kUjWuJv(^tn)u5^cgIn`qPWVz`iu_L5a3nnQYPYE97jyPW3U8q+ku`5z^;X3EAB{ z6CDrAU$d{`ev4$zALrD+ehBkevaxRU|*N`oH=xe|S*P5bVNyoYxf+nco1J zG<0?OWXa@$$o%NnM?)f$8j#s`fg_JRnHPauw;5_7ZNjCl1+WXD%1c3ipzrrr-xZ;7 z;O8XkeaMKE@&MT!ohiVQdoKARB%4O(c1STamppJy#zBwd$frpZ;5nw3PZUWe%j^}9 zfOn5wRG@eLQ~hIKG>yd1o07E#%srM~<3;zu%sG1KPJEM+F}x>jVVv6O*H#*sUp$ z-u@38|MlUs`V;fn3v08^N4Gkq&B6TfEu9jJCwmbR16LT{@A;k|mka%EJ&+Fo4;g8K zGX5+^KnSh_A+WjP9{zhXKa={XkC>@!$w!A7O5fiF_s-9Akv5lK2{633Y5od%VPXXG zBa&L*bq@Mz795D=5}S!MS(WbR1^c_op6d&3PFF7AV_d(31vP2A|0GrXG!SJk=l}Ul zXCCy43nM|E&PvFkMSbX^CDC~r&>4HdEP*VY>X6asZ_IHf(K!auIr`dyjy#>EfHKva z88W{Gm=>(J_}!U&MZJp~TB4cX;yCnR-&@E5yeol|6GgwJ^FaM43j0X`ibW7u9O>{_ z=nYvti{=89WlukiA^lP@38?t?eq}7#=PW$Ia~W*MNG8DVB4m70>Ic-x?Ze}AK$*J4 zw6OU5dKC4MeQM?(T)1qv$P=T6E-2F~bV@)T#?K0}&I2F%`N%4? zB%o7CXaKqkaeH}?&+7ny1sJG!E1sP#Z8Cva{y&06NC5u#Z6HOUcl(bVqqqyo^=7Xe zhrQ@e(t(tJSj!?Mux&tLPmmKY<1$$RbbDZKX6yL1#HjsG7-~lj;b;0Zz%<0u3A(a9`&6^P7Q4 zL*6fzmNhbn_NGfq<7K&^rpfw%83@xwf&pkh{}63kHk`5GlfZ9u$NOM0b9e_Jkl!>j z7)k-;XSBW~1QP@Ouwp#8Fz`O8hPF=#_TxY~$pm!Tx}huW+@}JWa&>npC;`(S%Yj`H z789G9O{60KiRF^Y+-5zWu%KL(mQHUq4j1pfaMr}1_z?3@||E+k=krp zKqGt;e!chxs_;#mneqRC0(6#}@)5u@E64>z-Zw;()lQdFYDydYY*$x6**9(OBS-1n z^89i~M-1&2Dt=;e3Y30WackA~1Xoj-rk-PS1?UX7cf$7|>;Sk8>&b4FTJ+Hn$@Lm% z@a1{!1kiMp*3ft9zk>&8TXArKW5ESsE47r|7n$IciD517s)aAL(usD;X^AE#3Ycojj(7CB)1w#&%VD}%#*59(%v?trfQX;oQ=+MR(A2*w94hDnE7 zM>C*mUT4Z)TQM}C0kr9m?#-yJj^X8^?Mtq>C^+KFv~7f0=q0A+rs>gDVqX8%F;z*oS6 zooAEcruk?7WSZXIw-P(mKw4F%On?srvZn>E%r*wlA#F_+iN&-baHY(z@lF{~K4#Rl zt2Cts*Ol5NKsS8r%8Mr`9%%qQ&F4G6U_F9RS6)Kl2E$3Z-hbKwV9rcKI*`7gaz6Gj zeHa4}lV>cmG3V|m3W7V{rbD{HIEaJ`Go-p25Yt^|cjCx0JzVzQNr>s3Hd<1uhK?k{ zgJ^q~1py=zpt@wJcGA6UO1kB>7Ye|b-{QAU2n%Lfd?BL&08V5;#Zg%>n3B?cuRkKz{u zIi@nlAabOh$)fgqOT#L1?kYEr%2eYv$7f7=1}#g5tD^PB2DlL9X2)aqPH{{VxVk8E zU85Eybkis{f!`M3EHpYm2_I6oA%{kq;-VL}Rrlqf6HqlyaRUSC3(SR$_B=r93^bLb zIZ=v5Qr(_k-Y2CB3OrsX|Ge$c8?-TG{g^betoSDMK+ChvJ^IlyeNtQuuJ@vp)J~u3 z48Vq>Vh^CsiUueSQ^eJRv%k#+FJ@4x$m~CdG{upK$F_pXa;&~HXeKrTM8w48j;5iH z%jIY-W+_DmJ%HJ8Y+*F-s}woGq?+XzKqKjW3C zyVgZ6n;Q?bbogl01CU8idBG{CPm!wV6vGJ)Zsfjqd;4B3JCC7&|i-<;4NPT9p3I7%>qavD|_1@ z^@uncsgwWHXwZ_ZBB>ue&+vb`&%>7#c^_yo%f#!A$4Gw#Eaj#>3*h>E13;xH;u8IR z!Ij>qPK?}J2yr69Zp1YPJOWjHgOlh(LG}#Be|j}|-J=on;O#;I&AbM{&bx!2>{jzI z2I>Y{dRUNvhKB&*u%Ptk2nP~SF8$Hou0~vC+I>7SipT*8dwO)a1N+Sy8udRi-bgxO^$?A+hcntft}*;V7C> z5*-)n`Pc?PR^0Ir@({GcST_NLlfDfAFd!U@S|+dv-6?XxE5(l;rGGPlb$YR0|_q4$2N5((iU)FAK=ee9+X zqrT(7kJPECr(kNT+3IA1P`_i~utxzf9kc?s9B2fv_KMDTL5p`|kq!Xg*S7QtCjd}! z_pJs&f9z8NFD)zlzq(otrVvIhw{~}=5E%7V(4nD+&U1$POT+smS9d`SrITe16}(EJ zkayYsC4p=qwha^+ZN3O_)3Pc*JlpfD&F5g7Q%$M()Eb~QaK=f}l5-wG#IJXQK}V;| z8346v_YVXesA*epZ)d1b#5CxYpNCM305sqpO?j}p8i(hs%YOkwCPzm)8kohHK2i|} zsjMs#q{9O%UCUzRtWnlQ5I$bN*ai*YKn>Yu6_v}4P$w@tsUa9()3v2Q>(iypJkGXk z2!zD!$h$*8&?GQzEblG|T+ywn1TLl!z_ac`r%PFhjycH1JGF5R6QH}ub8?FwCA9bE zz5xnvC(>@b`3Cr<=Ym%%`p?u z#vlHOgfJqPrL;1qn*zXufVoSr5#{MJo8cn8QZWFLo&oVndnX=?AlmXbLDFpVW)8sl zTfqsU%)l~w4dXaTdBT4lS^O=f_ygF|ILiUcn5mfknwWvYH*^vQ3#shLXVGOqz@@?@ zma^WM!}lK)Gp?2NDr?t+xXIwuCBoB2(rYS)$O@mNYkkMaWM79jV2Qlu+b=B9A+Rrd z@I4$BI)(PutteA%t^BfV3?OfDy`A7^OeVeu*)|bo)@T`qB}4v^a%;`q!*{&koK#-} zFF`LwtHz@CLo0b$51vf)3Hp5ivM(msdL1AWlB~ujfQcRgEm5lRQ7~X%Z2Bom$13|l zF^3E|Hod#R5Yey?;pM<4KYm84>t<*t#|8u&!uMU`hjB#`BFhEE@+;p~`4G-a@$v5O z6~h)??B4xEj>Vzpzn{?i znN_O8&8)Um6XhQTAkRM-kX@+x3sNh@CTGXN+VJQ2#E+Mjs82XKlku8hP{@o)O0kxS z|Cwy+3nwf4r^t402HhGE^Ev`+*aEQqi~wiA8NW1AYMl#iRUdxZZk<~wpE5=ZDS;wF zHtz&o#E>6uW~cA0ruV@)cWg655lg7=x}5cmXTzn1=%alnBzzQ1yc;+%tyygH=5uj} zcH=`!SVWGhxjYMKZ`{zvl4)=F83XfUOoJmirDqgW5WXLIRP1o{?-NL8l$eNFQl8J0 z7-DNykgsXgO*)#T6NZ-BY>qNC#{m=#>~JB%eXlUnc;h`ExKCp}Ro(71Mmb^rtzAyR zx#iqas}ep+6y4!oZ^c@WuNl@lMt7;A)d+XDH4KJQ2K{#=el5&x0>=+-vHUsiN{}O{ z1tdwUh;g*MV~I=P19&u!HQ#T(QlG5NM9}ub*U?6AVBsg`fF56pS6S~WMj`$o!k6|hh^)k2fEnH49`dLga;OzH z*-_Vg#~vomo|9yAVBqj5!MYb*)&bBetOU23on4&3L94)r$VLOR#b%S%)w0)a&h*&1 zk;F13Q5`-(B<*#=0~CC&UvwI+BEu#Z1V0Z0xBzF@XlZ&K2frbwEp+o0DmPv=l^uf1 zdg|ut>kolNC4z(TEJZWNoCJ;N2Mbhxl1rq7tijHhNlRTMRSWyCKfYPt=z&@ML=}zv zo6URgp~x%Chtp`=ZyL2zQaM=Dz;8yej2jDiZcc7pV*$~aa;-;0bMXXu& zb6<*KSpGCeCob`~Z&AAw%~pzH7XCZ^=L3n2yofBqPmCOblP z=hkk0QmXR(+-Z_HyH{B9mbkXtVxRLa?rlee=bC&n6IHxiQIh_Ctp4N*Ym~l=oU7hN zDevj*BaW@(bf%b|aG^%@bcoBF^*W-=g!eMuW&ojPP?fv&Z3^9FX-*VRXe-0-PKayh z84Yv0yZu^Zpg;N&fqoG%>;U@Eff|XFi8dS&_bn1~-!rjuZ7lXQj76S{DTpv*Q#{fge$pqAkIOp} zq!l-|Cr+~R#j8y9+@oKLr;38dGX*~h{6!o!3D(*#Z*Of)@&*Z;j4B^@)ih=MbC>c7 zg2w%}+j z)QLTrcBGbAi9;OHsr|ftp$E3XaU=$ynWF_vzL*y{Q(+H~zuKE*yw)y&vFx_Co}Lz8 z#9~;(K7eWI^hhA@M58@f3w^mqOjA}IZ0r^nsQC7+?M^TAZDpp4xr;6I);r#|+x>Cq zI&|BsrAL7`5!P|^&HgY~?}d1``NHn&<*%Ga-r@%h%|nNbMw86%a_iarZm$0CGWb7` z#mOH}SaG_$&vV{46(rVT>+cW0&#OFmR5;q{`(rnvQK0h7c(mRmkFBqSMZwBePfi=C zZ2%a5co@n#r4EWpa&!+<#i-q_3K~rkD*;YD(>#6BSVk=>@)#T(r%PFUHL+`Ja*X=z z7Q>Q?UFM#4#Mihx!AvfO93y3T@t08rlAv-SR6Kc2^H924saktf{uhW9Z3$(=7#d;) zyTJj9X&4TM2C(-u&)@j~&J(t@sUXC<yXaxvoC(9TBbKEqhSL=dKkzOvavly#oq#TtQDbAk|EMdK|<8d-J;M z&4&5a*O(H|y{g`*Ia|zlFyV;_s8}i>{%GsveuYVNwDI0Rgqu7CfMWrIYtxUtrRAwj zmo-%Ny6**|$7)`~;!KzXKYb_95xtsp*|8XmzK-6fRr_DnE+#o>V!pwC8YRflEL{7h&W!0fZOuxetsUIxl=BEQrE#;p8 zX98(P4?BSC8^g|PYKi36)*069qTPz7+JcD_#AlON1A^DDX+|F; z`%B}v?=v3^X~lf?(OW9K-0JasabxD70M%qd7kXkosodcwP$hPx;vSaCo^QMI#qfe9 z4XeRcx$X?YzAjGsCXZ%(BoFVneApqcvJ}1{w*r8eL9wefrjqe=?|?}8ai=6_FH>AP z%&u-V(VY-TeBKP$H7yclpt|N>>!K0PQjYg=eYEi{{{5{#hiz(QpqXPDo-_a@aI%4? z`*b$=X;2Anu=o=cy@a-#ybNCJg>nhQSY=rD{TEAOnu=WRS~Y$4RVr|WsVY!sfa-qB z4K{EPln@=4#|0+SHMgS7l0sReI?!NH&VHt^7&lK>Z2AEcTMUdn^R(o0U#dwbt@F91 z|CcKhA$o5$J#1x(Ts3sNm#VV4PMyAO78Nk!DDorxypt$R?-_gSYL35f}Ogl-?au*v9HPrYifl2uFNH11rvWdY2NAt(PZ zZsmpJ27e%>YQ;Bq1wlyz_)e(y6UU8j?kz1sqeihi-(KJxCX@Yb!GBFWZ8x8f)am4L z)UVQa*fccmGM6iwo(3n@E00h8_gMfTQP0*Khhz$W+@bnppo6IZgXn=VS5MF_cZrdc zdZ@40%)dlx{j0r|<)NVK9g26G`nptbVSLxtdm9*$od@IH=V*T3Y!-}|LDS*N;#<=fb{ZE}K90MNNF0pA4O8GK z_TBJ%)!d&DJfi}c=D&FwRa)`Fw^068(o2qWlVK|`` zPq!N$%<9D@2?_^wADk;Zzckity?7fDPE-Yl2VC*h-t{2L5yOI6eQu5NfdNeaX@Mcn zkWk~9wPmP&UB@aS)hV%FjbSG=}tPWGr<1X?yPRw+|aIY+S%_4KXANdC> za8F$GWUf+4z)9_NglwX!S#Ny$OhISAkEtbPFI)SJ^lZyS#W8SPwDxd3i&=QD^2*xdE<^&f&0T* zwMyVtjc45)dq6z2rp-*P7zjdRMvR`ry(w?Yjg8%E)^{999w`oRk|m4OmC%`QnToTQ ze=c~9mgstS%6k(Z(wJm9&^8k$lhvshnvLSL5a%v$+HR;EKI1lQlyfI~>rLj#CKjUS zjq3G+sC{+j8sb`@`4E}@y z!0=(0)16RVtqr2)b4i`nf9-x zw!PP@^6H*)$LQ`#Ag}H};I)Xk13EsKPMJLO65G-1l5os8vHASq*r(M@w&lE9&lk}q zyIRhnpTEcji&M_Il49xqy~djC1Y+)+=D8)Gpx=B2Ef?T$7wmon&Kq!1B3X7aeNv%P zihLqJ}%8em6z_R~3ck z`GLf}bL@FUlDL2TtY}$jnFvZEA31N*DUbh!tei_qLh&3+D08yyRQ`1MKu|O??lQk2PibxY6)Jg9ZGGc1 zpd+l?Cg#gaj5+$Me9YDU5Q}sJiHX7m`?Af0!p@PL7DtYq&ab>6X%cCO9gGOXtkBs$M4aMxMb zR2Y=IFLY-*`6hPQL2?@in>7<%aclM2Od4DYmStEjdc0CN>iXfpuqxd&xz{E%iH`8a zfeYzfG2@EhV;&`|;}FC)zOd&hiR0ja5HQ{UZt5Z2vZeNKc0`EB{91J2JOCOB2(n5y zCW5mPQjPU^UyGUB4Nqk+Do`GQ@ArSxi!M^9FR|(W+W(?_Geei49&PJ5nkVvRjVV`s zH_?*5@thEgz;rb@v4d87=$onC@cd6(&h2Rbh!0FfVa`Z#b$r5DZ33A&O1~I@6&Kse zmHrJB4%px#2rs*uFROH&lM%&=;yf%eHV>*w4s}PSm{ncjD?lH@Ojt z2@XJ@=JgD65m@mF{k6c~grK~h4rg&bZB(d)MJCO3mJC?0U%^h8o zW)?Y&yCAWh^8g1**(x2P>tkSgI)oGPCYDU2=5aA#g0SSagfi54RJsh1?&p>gACf~# zoD#|aFJ@v@#Q?qKndo-_dHDQgJb9#QY%O@MnC;5%^NFk=x5dhq2MLiM z7hrX6(UE>I>O_Xj0EnAn3soc#xe`#3p%=S}zY*nsdQvD1EH@)oMfIDTSHM1V8b2kE z|FmcU>ml78gh+jmuPPurJ!nx2|I<OOW`o5eetD7Y@?7dV1(pka zxj;{1!?Q=AcbTd7NSYj1WRoj_PKXSC=;nofn)L|0-dARg1hPm#VF=u$+L%`it=H8D z7FUu!ny5lnI28*ZAS`87BFW(^27*s&&$7wFfBvtrrG753dLDa@A(>5=O?CyrgSC0? z4k>tug;ZYf!twv0{hXlykcU-!dcOrE159)~EY$_K zFHCM99ax%nGOO45=4Yw`cFSJ0tX((Yos3}K959#$I%rkKtA5Jep34pRyR zhoXt@OZ$7?8w>*QhF2E-MW)-elb;w5`+lMcYdF?WjwJZy9QU5h;Cq>2&Ps$U!qe-b z{br$-f(+i~0XKH}nhCnrmDjC3o7XxY;ha&$4z}xA24&y$dR+|V%D+OPg{r`A zrAAa;CNbDTD|sX!*tc?b-hFEo%XIvKA7a?tKIw$QfWFs`){>^a`Cdl=s&*r(Vy?_< zsdTZ2N+m~Aq?pR?zjLzIMDN2SV-N`@4l89*KIDThH6G0qwaiztEpgqc#@D&e??)BO zu8L=}oh|Mh5vj)hS3KY>D}G+D2h+z$sEt|gd(xV4-mSX z`f#>5SNXr{U;Q~m(9v*2x|BC1>rEDn4S=5BgY|`rIry@%#RZQBae?a$Z*43TQf6sc z%us=+qu#m<>hM6_s-Ukex|L{c%{jPR`szji_U$!iL{x^kbtm9+reXdy*=H?O5T6+f zmQMeh>F9U>>>p$H@&xJ2EExzxDM5guI z?tC_0Q)V$)OsD6w?No#%>TPVaS=+t!WAlwIy%`ACnfH$?dksqN^x$Gs7y0g)Z$8Fv z^7X$d0!uV??vj$x=Wq(zw>gA^@Z4Sb@xeDr@ba~D2HJHaU3_(KL2pS)vT~-Z^6`QM z;?*5AC|x!bLxBmiPZMFz{?@TaPeB zN~odo-ES)&BFl4vZm{FT;XKIvj`q@l+kZCf>q6-WSjU9#4`WYkb{)e;uW4sc(s7M2 zN*}a}?WS!NeH1rK;_2ZwMq0)Jrv7);B=!(9*3*?f6*u1(6*C-J(qR>=T?}#*Q(v?B z;+y;0zzK?auGioY6;~Ljl}Ce?^B70*M!kzw9x}O6b##1U<(eYkwr(02&ies4?oG7t z&`IH*&Lej-TTse7^c>m{!^bO7B31lakfP(4=nb-*MRKMmFB>+s6Ft}`+?Q+bBsdkv z0(gpoToU_zIGZ626FnPTD9a(dM9|!hqRAv&ieL1sG2S#D-66fsSIKyxw+zoF5<)Yu z9J#8p8a_{uG`GT~%J}J=dEnQ`?sR#%tfxEsUK*OuByL1meui4Lt^V?A#qN2PZ8!K@kjk#4NG zb$LP=ugv%?4s4vvapC^I>Kj{_)>^1pY1s(2@t&^eT*sUF-xOEMvAm&!U5hoUCZJ=0 zC2aB4#g=j*C)`0F<8wmbMqrddNSqyBQ=>r7LJFkS2+4z4|Jb>*b4OQ*vs;^8sVel! zncDW@65fagM>q%G;1O1_{ZXbioeBys6C0UVN$iY?l&K7oEro`|aHp2p{>^O&J_Ro< zzf>hk`tk;ZBRO$3(6+OBqwNBICOX$)?tD}Tan!lhkn7XRwd#UkpNO~}Yj^0>w)wE~k)&=h}@EUDjc3Y!dyd-TC9W!@_g$cZ`A}<?N`a?5&Sd~N&wCm&*=#>8742NVmz+0@h;+d(JF2)HMt zQfg5^$eh0fu$Hb*!#U%bqNrBWyy)6PR}Z(45qQp8x3BSp55!ghSU4_ir3c?K-y&e! z?8?dfsipkhns4(=#Kebg7(uIm@MFC$T*YbqPNuSR^U*Dot~vG7IiaxZadu}D#E&af zEuil6W9`X7YQ=alI1F);`qTbysZDoLBIpkxS}_M`3G77K&ssW51r*U?lB3eC$dXBtAi(Uo^n9HO$$}C$2|3sVABvZhM?x!S4F^6VB@|;2Hfv6T}`@ zMWmZT9g);tSZx_C*3BEY{XFE^9$JJS$kV~JxJ35cSNv5$Jc|NV2$_%l;-K+2-k!$sh-s98nEx2$qoxZQ_Dj7W#%9#kD7WOI5R>0Og{>AfM z-pB#V1u>!R53{x$x47~l0o4=iY&nC822Xf^bj+5{TNr2K@?^Kvdb zZnvZ;C&)_gCKTw#i#TYx(;ZkDlk>|c_l}Vr=CdaHUGRNJENswY)_ z^PelPJw|u(1den?Mvh4>_az4QF0#E}_xk30C@CtbK^b`7-jcHmZCl}LGv<>pJ#9lw z>%~2{edN+9xmDqbQLTTUV%Q?ip!Xp`VXBp-Y`o@i0q`l+dZ%&+Eqy3m+E$t2KPC!i z{|Wglsh+t4Kn`JEZ0=GwC0CNAXSPtmEyw6!PfoFCoV9c=Lm3fBcF44p^H3gJVub}O#*m3vwdbT(}y*G1E@GCluZ1~5AkErV!O)w(% zoelrt&$Yj`D2SI9->Olds&B6)ROshc{jx9O(!@mGMe;R!)(>->yCczTR2u4Jvb(Hz zVJ0YU{jL4|cidwVfxWJ1mpG^=53Wh}TJz;j^xSQ3*e{8=6IMNM)~aWkKDYb{@oR}m z8>VU$D=mopqHT8*PUKzkd75DAymdSO+$*uG3^k8jVC|s_n~@WxAnHvF)JlZ!rtfL; zev0&0+UJSMtKPLL#ET$m6!x7X4!CJp309l;X4opV=O}zc(mG8!-7B|tb!Q`Y^sAIT zBq|moeVeKH5Ru@}nRpk_BQdpAHj0PpjI}s#8FvejRDDZyFB+-uHYP!(tlx!QTB%xF zE;v7GSNpKmt7f~+cP7kNI{HMyTa}EM?cE{UYIl67+m%rJ5n4$Hl$K|9b4ZRUP8B!q zG=p?&38}=0lt{JWz=(w>JP^#mhv_L z=uybu)14*O8GtafVl@oMj-=N*tV{FMwJ9^&l@>2;FU6n-ANsVW;~4DsO;c61*T=BN zxG}5=ToUv&Ox$^GV#~G-(y*y1wnH2CPDd7#0tQ3oTS~;9O9|IEz90gTy()^Eavn~- zcrle&vuk!)v1)uEt|e%>VEA$G(q{%@LV?>Y&Bq6l9Q_X!9-)2EsV;R%zXtQ)#j%WG!nGiH4h(LF85OXK_Cx`9HLo{z;!>{&F6B57B0*kn%4%IMlbV^CG zKpmo2y4TBNe5br0H=&5>S&x@?MoSkX2V__VMl%Mud;<>AjoV%(pncLwpgYew8-spI z-16IBv@|5ZX?!ve>~3hB#nie(oxwpu8v6M`=0^f1iqzahO`Jw~*g z&d^=s!l8W^5hkwffng!ObfCXLY}p}J2h-V%j{3?+q%3n-WT&PI53D=S=Lr!6s!B75DeX zq26ElVImw!Vg05UkxSNzxI7%uqt%+h6LC6y(b40gf~C-oStc z7PqwIo#GiS2_W&+Wi4rjR<7g z;s}Y-(>LDa?VjoK`IT9OA^`+cs6%-wo4z>iT_$RZ88SOa6LV*%{L(>1TNL;^nWLe)#(8=c`U`P6{{f ziM>6`RsL4(=@&J-%jfP~`{30oTvd6ULwS+88}{}ELbQLKi1%Ku?sOYikuEkj6D`Ij z4q`X?tyf+4^Gg%0S8Flx9@u>m%UOewOYbO*ODy>dO+QUeoxQHO>@S-dME67O2*=sfeg7Z}Du5Z=ti70dppz~A; ztkdhV6X(krl(J^p3m8W31+3Spf7QrdDWIL#zwsdFzU8T5`-69MjJpr|%$3UfCl`W~ zItr`rQL@f&S0Zb5^OeeriUY_@UQ8usBGzL`W~5;@37e! zT3cM}IfJ;!-*YBk1kYU6K*bv7aVW03V^gW0iE7LpHXPc208OtmM*cdDFRjVX%&+JM_V>a z<>fDz*XUTXt@&gnWmdbbcK*V>rDC1O93DD{3L(BrnS;#VDowrq77c8|skc7{uCkF- z9G^INcwvzrV}gWPoC-b?5HTw1+<%_oq4U;wvQpzRUo^jmD-Sa`re@VPMtXgD#Z>@> z>vz%$kSnq|8@^u0olx}Gk>h7%|K*<>&yV;A2YMe9lRA3p(o5Oye=fO4@4$VZe{He2 zCu%$OERpSM?+!A|{)*1&N5STO&jy*dS`9T*Ho*N=rRpm$B=fS;8n^K<)i>*w>TYZn30VYpjN-S_H*>;<7{LdgtNQtfC9~0}SDlZ>io_%vcBJYFvMr zj@s5lbHCG4QVDPiWfCrFR9+TDEVL$#t=)3cB1R{k7_X;et)ktATc45(HplNr5zXZS zCA`x_Xu7CZ25+j$-bB>Pv#U9Wu4zCzL#Xpa%BsaVU?-zus@?ePv}Or z(#?dxWA|O}ePSC44>B#emqVbp-gu1bx}+(&Md*K$fE7>o= z?=`(mnH3rrTEc~7g1}it>XLpCYrx3w08%a5LbCWhX5*>0` zT-_IaeLtX-u4%KlYR72=_j48IE5!>THEsE`zt&Ylv ziH5df$~9Q~z4BJ?noJP3Rnz=EP|LVI_OqW{KPxn@&mC$l7LMT_V@ek>85*m3z7O<= zP5oc%B$TJ^=XWgcudWIos30B{+V%gS{s52PuN!n&LBRE{prapWRu3K|IAIfrrzEIg z=G{PHFYbRfDZTYPhaS0tX#+z{wttP&q~^B{wuR{6)x#@$sH18}sY?&D_Px6@`X3fl zeoajnv_0IYC5|nwU(ZvPsaLDCvG5J=zJ$Yu(2wG`FC2U4M{!R-q}45HIfN5Z*T4S1 z|Ga;2^f=wwMLP#x()XNvvK#!%JcRJmH{SUef6-4lBES-<8K?28W;yZi+x%-EY0VWz zMZ5w6T#s$d7|w^+sHjBS7#{d4x-Lh>TcNg^d49VU6pSx%Ojy4d^hDi zcBPk!aR+q5>OWp+X~43&YOU{ES{VWaqh*WPoT;HfX+-1mi zT#Q+&@8yENkC&Nee#6u$nghc4s}j9-`;Cobsy};Zd~*o_9sHwVKYO~Njr#>;>`5OKJA zxWD2bYz3R}Hp5M6k89%WpRc8BoVI@9ek@x@*y);*TYSxNI+r027Pvv2MnV5~`wD5H zgY3!5pY7)PL=VPA$@^%-@;J&U-VUDuM%$HdO9T%vebY}-P;QGhVhAqZgt6@;TARw# zc+Hgs;(h|`jxX&p?s3kK19ckVG|3lDCiSJe!bjTV6vdvz(K_Z&$=SkBNcgP!$}d>k zVs{bZs^_KiBo>aArYdffd^(*x-}ei*pC@-=85>jf;JiSoXSRlKUrN(XvC^@^HAzXB zhNl!(rq6>W)#}7#Pr9I?GrUs;7do&lowF{A?I@F;yIMKh;C#zA|45ym906PFQN6F9 zI&u5tkZl7>y96T)leZqsD_4IZ{4RezP|}kq-7YvqhhBw+cz*N@EgzFS5s9!@7d>{gux>DV6xDlX(w_D~s*_S=1 z@lg5l`)7?dW`~z0=wK^n7W=oBo-IG8WR<6+SPI;`9ha|ZV8f-sa#k-X?x*#6{&C+W z>-8TPXGD?y_%Mb-_jBh?jw(&cBHL`K<>xGuj;$(-#8UBWW-{IPI{Sz zk+83q==w=I;ZqW`i4)p)e5+OuXqZg9x&+F;JQH)64?enetFMU7zn3d9STuKCnJ?N( zOd;5K;ID8I;D?h^YS$}JiE%;sK@(oO87aB?92Z|zdUa)8;+{E4H?ukXPr+o0;qB!m z-l*vJmS^2g*9#3y8f7YUMh);32Q40ceaj&%sa&P!Xw>DVEV$-D13Njq8l}JS7~A_k zJm_Vea7w$y>PrdXxlfVERm>ByXX&`vYwml)D|~bPgXq%WKCY09SLw<#lf3Q`b{hYw z7VB~7m|ATta6ahl{+oNV<^q_ur>Ef#KW!zUyYZptb_AJRmxL+`Jqp9j726M3}{-v9vTTQR9;m|V5 z_9HmsYkJuO;u-mj1(h&mX4Fu?hqp$lgI$mxC|pKC7P zrGcuTt(*!=@sfhBLfAN6K-1$%qg5p%T#XoAaQ-Ezy9~tTf1ofEdyH5V(K+9)e!0)Y zqU&xWx7^wyyJ?5enDJ@WphoWea1TT8n|vxhy>&GWYIkiG)$hI-RZJ34mD#sR^jXxj zYqNK_C;8&YM~jp@EM}gQUA5{r*wyd$$sBf#p1{QOnkPEGqDv(HT>^1O3PEck~Z4C~FO9|YLBw#hG z+vr$ZW{R5;C-<}67fu@Cowo}(fYoLDxPu)u!knaLHDy>llPyZXbNRZIZLN8Pns0Qm zJU*7ch0wZGjPW^7J-9qjnN2(AS)FY_PrFbxscjdevl|eMiJt05N#C^I=%On^4`<%b zpe$UAFN&MrPMGm6d)25i1s@k)a)LcuJz2RXnDpWE&CFo8(8W@;id|B|HM0i`t%+^> zY!rJagI<%ck89(+RGmnlf`zWKif zfpx8IkJnxI!DL`kh?k}nRSTa6XNR9_>a{ItZsdx@8X2JM*R|z|i?HI#O;_mylGS9D zUvvMEaZrs7H~ZnikSsMiR=(9~$zXF42;ANej#)QB?&7i|Zev$Vt@}^yV$BWsQ>;XD zi`o7`uDd1qQqTYWZ>fXqUw(I?bm4(pfb(rAwRqqGatUqwLc=L!frDs0HEc<QmQodxRhiYY!YVfn-_&b;ES)y#^(P-m1zMH49@7gZ0+1mo6cv-?5hQ@y8|s+a@hE z;t;uZypFKzrF!1J%4hK;9VT{i;U>;MRb1bzno=`%oDPqXTK80h z%LP;0i)DS6V=`-Y%NE>FStlumjMiaETb~sZ2w~&bb)NO)Q{#WwZ#=H{!r)bA@pNXU zOWLT_u7i`qR<%eKF_HEyR=S&5nRmOjQ_45^Fqqv?k)5PrCgitWiL0J=vBda$UIsHp zX_%6ULqefLH^TOZ1Ax&L+2UMr)p~nwA3zkdUS;X z@i&$c8mZAsI?9*Nj4R*FlD6X#wH>M$UZr;dvyINpaz2SJSw*`-?YDN&)xcKKWySltN{Z+B#Bt-QPITXSzPV@fuxZk$ zo{_rT0;T9)ZBc;7>~{33e4y%-mhR0Uh>tmAU}S=!bfbiHHw*&8n1o2T(%l{6s7QA=(mB#F#61J6db)u@Dp~Tg-ve7ci)6XcQ?!w zYSh#c31y2kPfwc}+Blt_v{?7xx|ldUtVIfLrgvb^VvBQ@;r4aMe0yC(oyFR_Qg#j^9D(%b3L6VBMS6Z|v|qbs z^FUeoL85YO);ZPrPrG7Cxee`g5ali;YdARDdFky8O?yosn;sLnKa+t=g3(Q|6>ZDZ1Gtd#jZUYMn6ELfUKJ&C@9PO4m&Fs`TR{ z!t{e3eJ3hJgc7bAtR1)_$5Fl%yr<|3q3IkO!Ri>S72((!nP4ehA zE=_)oC7#)uD|va5>MzliT(&+wQT#>urQ~ zSCx_ak(?O(;+Hg{9Y^QRWO_DizIsx$gRaQeJ6om{h#_T|pXw1IS!o?WZuDE1r@rVM{u0-`zCdWPI_SGXb8t;WYrt{fsZudQ1Wj~$iO;cTh03BN z@uKb9LR%iX zzAMXt0;ZmX@_fca2Qtdla@G|ivawEl_A>tzwW124P)eC>x_sLD-2u*itNB4@jV8#;hYl20{CW*u8CZiu)c^g9W5gyeqV;1J9k z)q!bu%>XSOEI(p3{(krIR|#On)pGaK$UjV`dwZ^5Lv)T(MrlWQ^Jj{^4Q{GiLWN&% z9;nmTh|Ma(p5!e9V$tOF=gBk1{aJDw1Wt|Ayig~lT2&>~;1Wz17Rf~#k`t?pMwj7I zlM>v~e@%8D?auG(hAg(751>O)XI7k=y@zDC60tPUlyEm3l2@!inUZaaT?%<6+RYng z>lxORX1&%rGfju0@ojTS&0QQCl#@L8&JB;+GjbH|!Ow0Gwz-0#!K6i(AD0TqT13$P ztQX`CdUmfJw4P5O4Sy@4YR3J6>mO-KF4th{sK0F5&Awe7X=zyXtltDr*mI@qhae?c zdt+ClBj)Z2lyPs45>AMyzR3#-p;mJ5h5(4|aw^6jlM>G+jm zy`9N%^-R>aY4e;Z1Bd6)<9$50L%@Pt+z8#_7%;S)P2-VcnzbBn9LzQw$v{*{w`3kE zJRh!Nr^LK<8=u;sbM9xnMvj{2yu{j#u2P%GQQi}lk?I}QpQ1m`j@hnEYN4KNJl=S- zI_lRjCe~K%KQb1Y7I1UbJGbYyV|l0lNyL{Qi+s28ZJNgFRI~%uOB@DdZ&}TjxjNrY zXQGzpaW*Y@ayL}h7SXg~UbDp*HS{T>=TfmgbAuA%?WT`KsC-@QkFVppJh7(bDi=?y zhv^0)(?w8jSSL6w8RDKxo$31m+-HoHH!f3S12*s_lT8szhItyD^`4hX$n@3;ayAy0SRt+Jk z&X+o85Gah%CSY~3wpD1E$68y1akf#Ef*_CvMXo~0r zcv@>rfjTZiBkv;zwbXC1W3q>bBrQQ&QR=2EBp(+r8wgiQQzKuKb31>O$eD&Q4zdO(7-^6*TF5XVH-m{MUg|Mek&R@hL=TD#`oCZIMz zOSy+Dzr6zfb&&i)D%~VhVVuYtgIFkq>A6_=eV3?jij!U%VXe+YHG&1q+-VsdU z+0-{TwAVT{*DS$N?s;Rk_CV=p4`Z?Ymix4*>1oAFQilOMM-i=t`x-&E?o9;ke& z`?M`BoGo+QuZM=0f5MWj#K5-ryX?9Vd33d&77md{2*1dfOYWzLNSxXn(MVIOGWDHO z%NL1)J*xrJ?h>2hNiq&cPg9h#D3a@cn`csuzRnE_V>7j~iZx@5=yZ4>V>XZT_}VXQZp3B|&;wpTx zv)N^j8NN@tP#gNAR=0V?KFcxhEH0*BTlslBug0>$fC(btt@Yk#9O6?`rfHap*EbP} zY0mlD?->o{i19lPG;b({9;kW@7#6Evs-$v!{{_4gxaS5ozUyk^La!;dOX4-s2Weic zVYW55=_;qkiXXW&v}t{l;{AD}m^WaYv;6M1keivS)K0Hee>zu)8k&7Ge0(EV@JZb9 zPjg$9>|I+Zcj!P;MYb5p-~mR599Ho~vc>CH{e;M~h7KlUZhHNWoD&EwKH> zvf{n;hacNhx2~7(m))IjuGXEngS+W=f_m;6T}OW$UN9HIoPK9Bje~q-<%9GRRt>~r zY2RI!iw0=v1__aqhsFJ+N?p?QeQYPt_uTQ`SB6G6q+b<@Djvg)|2&V|clYo`UZ;(a z-04}?p~0ccajzzqoKb4s=qE0x5gZ~h7~_&e;*HWok}2gIb_LBRf)k^4R?C{7oU^Tc zC{rv?Kq+YG+k9ZKFjc6THN zx|KNI@G^&ha#664PE2E4Es;3G@>XfIY@eXarv{?85$4KFg@e5441a@=CQ@(mY_m{1cVwLv7}yQD}Q#Q_^SCR_2H zE$VINCgYrIlh1Bmm>t`&kD8xEV}$FL$|iX8C32Fi<_dh{*0(kz_Ht~7J=Q&sBw>Cl z|K%F3&7n+WZ=Xt%Y@&94Rk|E=1!^-)BLM#=*~Dsyyt9o~5qhghVp))og7=bL`ATtW z{5k3PfLZ#0adGZiZ{Ka|1k#*b50{2UoguXO+NwY)bfKY43GI&aw_=J_uwt8CTJ?7c zn$I(@xVFRhm8G>YTK;8Q1GO<^TshYeiv##z8e3Lsz50iA0M=N(_)b{nD3K)|p|OQU@Eq4dl5IE`qr7&7xa(g9jnuNqLU>tvBy24y!2A z(?Z+YCd59?=wY)*))}{7o-$cH$3&gRIh5cyKY+ow&Bcs6iTmBnv+%=lvzoX~zTLPy z6y@*vk>ld1at+z#`+m-(y&TULnl4+qnY}mh>lus|N=fC_zw^yq;**H?!+KfO>crXk z!J+I1DPL&AkuN|%C9^Q%7b1$nL4HpX+J1KwpZw_1P1^_rt#EQP!L^k+qNV)c>ZcCI z-Btxd0G-6K*)lC}>Rk*0o0~t6&@VNW)*b1Mr#yuz$*$mc#nNX2TR8L=uza!$rwp-%F~-Ko~3D-fHdVQ$d>mcA4_ z)Kjs#RG%dV3S(JYy_G%n`0L_0RI}kS$`jd^A2~pAN_C;d#oaNfXHqX#p6>)MNiNN7 z1E=%0q`v)!E%6QmyX0*}7O|ad=0|o4YB<3Myaxioyb+@4x0EQ?;5O;L=n_01g0_sg z+0!F_v<2@ZW6RX`Hv72pgY1U7jzW_s5o09$tzJvcaq|aP1rjQo=c#fVr!06c&g9MV z&0O_nZ)ld_wyLw5&BQ6R;GI<5sA9D%(5aCx95^!Fr&mzc5gIZty(QkLqv4z_jm>dZ z=&xzzXhsJYu9h`btQN4&BjoPsJE%T!@e_~cP&;|0HCP|Xz_zfsPDL1@-=>n<=^z{) zlI5_h{3!M3BPCsgUXy&Xu61G0^CGpHU+fsk*YLE(th0E|HP<*-WsA9yy-41~y5bAk z5xh`w?@*eLg^T6B3hK@^5oL&#NT=qXW42#Cv#h6W91$8$TWWD)__##Yv6Og9Vk#;G*(uOKmVlH_}iv&u{9UZf$Lpk~DUKbif1}eW3wI?h0!BQaTX=u=+I&r^NP&Uo;wvw5?e48e8fRA1QjD)!oGTfDd4Yj4;!|#vJ96m2-8erk zHjV?yS|`X;#9F~Nzq18#dv~APYrHw<{Z3tEd-F#xfUM1S?^>f!YeQ6VhR~F-W-;chr70h`J=AZS~4>SRtQ!goQt%M3%2C7?mF>^#K zDyUtB%-!tbBD5_N;x&03r|HkyB3vKt>@Z+q#d39-sNGdMKYS8R+ntZLS| zs^%xSqrAnc{K<5sH(jUN(6q8u!Chqffwv)_UJ`Q>-v*p9uYs;0GWWd6gT-KyRF<{+ z6OLQM{aWd4lV*82#(o%=FtQq3FKUhCY<>T{nGl>A(|Ko+xK}Yn&M7z3W;VQXBOz(m zT?txsWc#G|y{&WRHqD3*)2d0PhyUCb_!dCHsE0d?p*TeN*PROL zWZgDkIGpy^SEL`J^Py>)81(Fnftg}m+rG8sp0dF(@u@V?AzNNK--P8!r56jjhJxQN zLp+H6CQ$h$)3Wurb9RIMc;XJ;OJO$7)|x@{}9U#hau8n0(dXk@^P5WUVk^T@^?W0N{+ zdZ~oW1o3nZGHl%jEN}euDcGL_P51n8yeiUEs7yBVZezwK19bM@? zR;G`STc!H!qO!0=GC}|K5v^j=*QY~8$7}dH!BLWb?x@|{f8YhqQayVIh1epKl{#W_ z-D2biyUL|_O zIrjoZRM&h*T}UP8LTcl3=2_ki%hR_lNT)vZbui8mfk97F+-wJ(6k!PH-cP7tgRA}MR4eDC0GpkNJuWP4A&=+Bl zq%k#~iGLiKZ=Swf%4^UnuV!uqlc(ah9wQA#jmP3o~IRBImRe=`Y%X z0A97%HhZCO-rGFp_W5&t8YrY`+2YNBjQ5CoE>oKn=dqf53H4`c1iWn!+Y5>tFGstY z*axO{*Ot)ZFQ}7!*WWa)&bi{Yt`LRm_wVq(UfFKKd$qMHccyKA>><&K7i+58Q#Yz# zZ#p3i1F@K>M;eZo*Gw#sO?vXl18Pk_O_kiTOhQYPN0&;6HaheX^$qivn>XJ@A_BPk zxAg+5%Qg@#Q!Z3&JZxgpTmj>?Lh|Zwmz3IE8l&WbwnW?fo0-0kjzvuHKp|1~iu8Cy zTf38F?lO_#&eg^+`+*?h8K03W;-PySm zG2lFRF8KWKg05G5WGhfreyfB!XBwh?w=|zUrYq`Fqs%+FzCv^D@kTd}|+u*^|C7@-b7GO$y~fv1Vlq(-fOa9c?i(^WG7md6MD0 zRWDBNP#QF{j^JXm@b1D%pePGAozmN*ym6XZA0B?gdrqA{lrS>Mg(OWevqUyonZBdK z^KB?s&rr;AZ_cn(%qMa61_k7${$O9NLjqr8Ans~Et6M%BfAc2QCF_o~KCVrOx;!(U%L@W2~2da8^RZJ5=Ia3s<{V~?~aH0(&B!);4F z-6Fm4^GnlWBl}03fDZ)rR1H7Yo%O+Cm~QXUQ!aO0D!!24;4`41RX08U0wFZ*Smd^T zqA5Dn5-*CZy>QCBFFSrf$$ova9VVHJ2_3e+-{0%RM*dt5QO_2S$!go2ALTS$*=7ZU ziF0wCW@jD)KttOv5Rr;aS{D?PN)Kuo)mAEp*hrITdaP({@XocJkH_*e5p$-U1rNA_ z8g6d&zFx-E%ti*z3+A&u`e&4(Fs<*AcZ_yxHEiWG1P1N( zh0&{OJ>PvVjKU1uxB5NW{R!YJPT8!F2I7)l9dMXt(Ql4YQt?|x0BG8b!uVc z(O?YO)|%f0@m%);Z>#IB-HmDqqHN(s43Du<56$JfMJWVU_IR=i_UC4DIVy$ z8w(VgrQdR?N%MpJW+*Z6Q+T6tk zlgoO!ra#m#jmRe7Eu!n8(r;(Gq*uO)%4s$AxLP8c-2A}~ zkKd?sEI6R*vM{=_H~aH32y=}WS^P=RIgvrlZ`NxUy z!+m|}_-tl;9)ErHa)mq<+hyNozTX&W%Je8sJg4q9rX@O6Ayt`vd8*CNosgHoV3dfU z=_##L=!#PFW~OY6C64it=<8Ldofnj{~fdy7iW}B@R>YQ z0%4~QS0G#UwBI(3;^2{CHuUtufHs(B)zxyzI>`0Xy4;1)>gtMR!uMRMQt!UKr6~G( zT&Sti3YTn6^z0Ae zHZ>7JP_U|IXu1;QP7=c0-oW?>hw3>D3=X*u-4)NTSbjc!`FjYbp^?y|c*(k~y5?B1 zQJCX#G8Zo&n$~vxc#cue(ta6c-L7P_2u;Wzg>dMfcgH0nn-vR{x7-}@lbR{n?kus- zhIzt=uK~Af2G>t;PG-U_GC)7=H>LHDBze9Pa?Wzq&Sr9Ka$8ME2Xh&ROrH-jIAZ~N zCo)0k?&l8o9@lmghHT~vwh6`#qCm3Zr^ z)=bx%sb?la&;v?Qw?6EwtZ=Ib04S4LRwkN6U0sV^2SE} z`TkPWYx}nc4&Av8br>fLn^Z6T9YOkcReyz9`l1_hs8P!Hl?kpJoM|Em?oCzUZZdGEplBTm;A~xk2?^bcty7+9MITtv>6H-Qcq&)UW!&9K z_x3e8n+c}bTG?D?rzMV*u-Imn5dyh+B76-YoMM;6gETt|X@*`Q3wZ(vby7u?4O=Xm zq+@UdVZaV6)?dILlTCO;B@ihaz3BUl^yPbym(A8|2mo8UQ;;JnQI5fz5Bgg#HVBP^ z%6VTrU&c3~5m^P^32u7uO<=zZaQPFxhG``Ke#^U`y2_H?pBjwz%wH06=^R17t-fNF z&_iy8HmeWgsc5X$3p4lRhlbokE%nROn>a)Rj`ISOUD@#hY4R6KU{t`haCiB|V)SQx z9Vjg2VxPcW{;VaOIuJGO zq1?VQ@sq-|)}XxN>PtsNf0KLs%ibB14n3gaZ;kfjR7@~l7^&jY92@wqcnewNd=r-_ zx4R#j&l^c`c4rh*EVT^1;EXzzsvrAlY00XX7a&RY6<+v3asu!$$1r-<4b(O3uMEiTbg^3JgP|`Lz`Z!3 z0QbYrn86jfKW)}>@0|JVMw;uOqo19I!4nsQerJjPMT-k(;a#+b5J#SZ=`$L^H=I@p z7*Z?1h_NQzEP+P)lls~Q&<@`Zn)vG~lOdp=+X)_MMn5~eMaAD+>X^^J@cqI~v^2^O zD|rO+X!uJurjm6Uqizm8%1xgKV$!tx;j+CLX#w*w?)A+Q zXyMuY)gde=CkK6LCD&zkcUu9|cAzcu4t-SjrhbHJH*6mA+9LBF9Ex<=z7o!B{SHjI zb+sO|r!X>VwAZr1|B^(AwsIvtATb;OiSfE$VEol@KxBWR!uy=AF+|i;1jms?TcPhC zO-SdnObiZ<)rF2ueDuzX?<%pk4Cb{?a;dJ^gy=!miQE*CL*>iuDmI}Y3^cs>7w+># z4!lA-uFPbjD7|3S5p@!`q?;ENJW%oE9x$HLu*YSau=&`)?#dzfWzIdtm&~Y06C8YQY zQigCK7Y3gIBO!J&jc5c-)HRBUBj$EsO2UKDYX2uwQ!s-7pK)rExDvQL6Z4gVAFd^n zV3^aeT^tCM?A#&2f*>C)YicYO z!0Zc(7q@azFXw#p#?u-@wAs*Sk7oFG6ezx)>X(-z7;vfWqDljRbJw9S>ZljrTt?3q zCc6p30_$n{_07cwV~)uyv{Qe%tKVP(Wy$U*4QfeN z2n#s{wdBpkZD6#?jjVbq4U_!WfH0|g`aqO0!(ff7OvBW;c+yk3ZRr{sFjn4AGja%p z?0Mx;H0VwwnBl+d=8w^ZRbP5b!I_l~aZHyp+5~6v4B8k=il1_R0Uxq!6k#TL*at*R zo?1j7x)O1tIlcXiU?X^yT1e*hQTyFCpYnITsf~-hR+yjCW;m|S*@W8R0ywPS8%B?a z-hE3!dV~!IMNt&4_B)oc1Yx&(9=4m%tDkmyKih33y}vdX-CV3?tCI7Sj)Jfja&6N2 zEjy3(5ok2n(=T-b3ycGQ<&rC3| z?uQ)w@w1@d{lID}l&g?MnNsoDo-lk>pv7!YQ@lKu+0Nu7Dx~4 zM~{Qnqd-zDewQS&dPX?`>Uss{Nf{=pmln-7>_kUEysRVki;S&iJJ-c((|~v)Kt#p64)g1Lfs>CEKfRHm-x7z_7&S0}i4NxAq+-YKeL$UsR1KpB z%!-?f4d1BXR*Tv)ko)qAl&LyPr)C5u@#R0te{{ocLg6ew3^5r6t&%tXb2`1e8cBm- zfbnQ~5IV#f15{vOvO#9g(4mw?C66R^BB6uFdBX}y7#At{TNk$~g|@Yz)N4>qK~2Xy z+x*AfrfP5OAicOBBiwcg;ge=vbDM1$h8<}Zn?EaiemrGgD#OhA!B2H|P@ET-jPaY{ z2MANtQ$U$BE3bwo|1Qh^XYoi&di33Sy99LFRCN8cSxqKkY)a2P1H^=M2l)tl&Gz~x z&@Wd;oE***eHaW>jGbI0lcJc(9NG}Z^T?!N$dkFI&yA3Z_l5jMOVkr6dTTodu9ZkgTctk`8mCmC?t3b`X1eTb=Y5VYPnVa1!6;3Zhhm#%ip&6g~CahUXW*0ZS< z$#&0`V{ zW&J+9BPvd#^db3_1Azn(L$T7Wu)0w0eAIm;GB7Xv=i!4#UcCW)+Ih?5Lg{~VH~+w6 z$jH;`yCEHLHyJ%B!dlqXOqgg4o;}1gCFXr5r0VuVBPM*RcvG?n%~io+({GG&C7=vo zl&kJx99JS()u#cu;ag-qEn4-C-m+hRfDBM;c^CsTsnxc{P8tc!4p_oQYZ+M82nmQS z^D{l;)&5L6TN}$V$?`0T%DGbrKCgkyezDRr;H>JKxKtOvz9wH)*kyU_t)DFYsmiq< zmcMclf}SGQt4P)Mj>6-F8x?o@xMkXe)FiQy5Mp6X7vPazjjW~D|7FGPnsP_!eXJ@( zJYJH}wh~8U8Y)|=J-1y<)JzV&HuM&Wd7$rF=ANE4B^H210Q-Mc@bjXeMyEw;w%$Y-&Az4<;bAj==v*4)s-KJKbb z2C(y5TW`x>#qclTO*rUp1MjK#42T!lKp`Um)1OttA2Q)@|3~`(4DCx*Z-+T{zbu0# zG=tuIX@~xFoWI+>>j!4A+!&xtiVFL5)F*79^#%{_?ccubPanA6z@F`OhQ7Tm`h){% zP4F6s>@oG99{h)e&^8?fjU6x%Uog-BJ`v*b*nzzR*_4V(z$H3PzCEv`3EJgvyZv83 zOS^pJl_-Pnpw^T?vFgcPem#Uw@)a=AT&?TIJ+}Aneq+T9%=I~H6ZsRKUVAy(`d_~Y zFZghrgxjA;>^%dkzCQh|i%!jZG9kN(hI`W&lma0p-Og8Ozl*8A7%Th`pSq|q8iyd? zq{n2t$M^l}Tfg6dMgRO_S~6D)9Wk-OEFB}_hxt-%XgWxtN8se`76l2@N*=mHZ#QuW z$=jD>pKR1sBtXop#wbz!o|RrdR#dd=6n%`{&*2JQ-9P{2|2mle`{#W!R{_VB?`eI- z^iRfFyUL!#&vXW#40Bkw(205MVQ~{$Odoo2gvec`?!=ya!)Qb?0ET@+I{BPQXMxf+ zo7udK4KX=*K32Qx6ow|`!tZK8|4h=-pAHq*4pinF^^uW`Ke+Bflm1?vjy{>@6hHGI z@-=FgWJru%WudgcF?yTHYk8Wxiu%#d3|Zv|52V;k>UXfe(8Ps~{7*eQ?B~sU0r1#qfqw%Sxtm?W6 zZ0vMyAw4z{%+3CjfDgDHAvPNlp|UAdZWF!v6{Vf-bRW%rkeU^SFk?A#;#dp-snU&q z#Ia;q_Ym@xSS%a(zpj5fD!LzED`))Lor1At2#W)~|RRNSrN{mqI0aVf$lPj?3U(z&E|X{Hx-m(c}6?Lh622qQIF zCNijI*W6`LJxJc%G6_+C?d38JX2JxuQZp%L?|U87jd3jml-xA{cZzi?ye4y{^cJe@zVGP8E-pI{kL>Hv;y(e(Y_y@gy^GaQKAjni9;qq1z~j{47AK?F_LgWt4A(u> zZrr2uBQ8~@NHzpAsaJ51%Kxs`=8rSmRSio=+Ee;y`D}FWJAAc}Ni#a@hP*R>a`0EU z=XNL2{6vmY9YJwNExZZ zYMM+vHG1iXS&b~e_zF)@C-`eT0~ z{(shguD8g3-9Ih87)fA~T(h4;fe!RW*OZxzbFB4^UY$B?k8^Xns3F4+ZbmFlwWNh)(J&p|vbHD409 zHH}(e18pYkyE9RYPw60*uhZD^W7H|a*v&XV%0;AdZU=zH#%m+kHNViKsx+ojjT z$oDqz@8^Kgpgq?K>)5H|Ikflu12!MV3X#?MQwerKOWqgBe|L6&6427m4ne(&gIy|F zkT{DIRa$p+F(D)MrIx-C)m>gVr4Hu8=YjK*#QVANtxLp&Gx}dej9D}C2MEuNd!af& zfz*Gp+5S=VV`E4O!ngobf#|C;SeZ9e+ppRewLR@(dcVx8`tVi}s8p+BUG6 z;x?$pHvXL__db0Abs_9U1ctw!rF{jvU||1kfytkLGU^ztlKBvKu-LB`_rr1hU{N;P zste!A-5l6?$#G*zGf^%1uG!$z8h3)AJ!9fuX;)Y?5^}`UM}NuP@42uXRX_S#LY5vi z?S@zd@8r=}HM~d0Nsa9@4q|f(|LpYq>4O!P|TLzyHfD|Ex#BqIt&H-p)mRKXO(W?W6a{?k%9!k-bCvc(zE+ zGp(G_ zH76BX&39L^1Ux;6f@`CKWZQqA&_7zxZ7{dUD_h#$)eRi}Sw)MmY9Ef~%s zY)-Ch3vMr6m50$SX8+|r@D&lB%A5EP9UIA@7 z_9z%D5qoz}q6MDQdigqJUmcTW3g4+(4DmE)+;mn+M0btHe+NZ5<4RrR@+^n9P{~Z% z4SDeBeyGWV7MPm2k>}#xRaIe`XUCQu+IDSjK9PaaP1BRn zm(MXhp{5{7xomevntdOk0O@0JKKKX2MfOCB&&)36;6haTyVl_to!2`Au`>=dD~(SU zpxCwQptV6&PGE%9YmY%K;fh~|_@9>gH^vjD!wQ2VR)o8z?K9ylaK&nOsFvK34g5c& z&RJ`tn8O7(MsC~dOr1zn`WRAjbu?I0paS*G?d&gJ|0h-U>;GJDf#a)qY~tKr_QF-0 z9JqLg4@}1$&!sSUIH+;VBhe09GD-SCX*M7+GfU7h7xIM`q^0Wi~vXltWL2e!)(nGL| z;SkY0!Gv91KAZTkR5@LsNQMYe07U$2h<3Ig9HYK_@iE;#grgw# zEDopB+J+0cltJ(4yH-i5cV3fKEI}z`?&9X&(MCmKpVDx^b{;e7Sk0pnSp4E{(dtW( zZN+P{`FdfPsbw^(2>1JYa_WYx?H@{(I!TEpYg-?_+NePhV0B`A7;~VSMcD~zF6FYd zDL&|y(W~+^iEBpKCwAu=y{C4Iqco}Fl4OQS>KpXYh%2qvYdMe z6IFQxOq`o{LkNGrroY}~6Kv_O--Bn6_C?rB53$F$(Afp~5l`(Z;!jy{+9gZlk7G$@jHeS?q50@$|mZAcwJ%(X79Ys3|9IQ^N*bUFEN6ZB~cR) z#}topOh;XSdGn(yZ#E5(h%;B)8>4Mb8TZ(!E#&C8Xu}SaiZGDG)1K=sv|0C8P`?5g zx`wQ}hxe%Hu|s*YN@&SJ^4~Z10s>3Kii@^6hK_wGq-Vld74#&(dtPOO6kiQ`YFwB) zUNE{{wT4-*vF?|XIKeg$nNH)p^zbaF!Fz!(t%;9f?H9)|i)Qw7p@Uixlbt7BgN{(U zY?>>T*kxKxw@UzajjTmb!(}U%e`j;iVyWp~4FPvAaf_gl>|YhYzw@gPR#ToHVx8f` z6%wAnJ+U=fq%S#b<=F9#eixCYl=;{rMLAcLqjonfp^zT&o>{3`P1LIe@|@N4q8H&-GK<^@r6cBIR)~%K;mPso4X;|OUpgaXG$rti}@~mZRkHxUroCbka zRj(lt0xEzys&Se0K3#QOZc`Zja$4&7^_yVTUz&J3l(Qe+V>p0i)Mw|W={Bc45e3q# z-XwxnHEKI1G%ioaZWB?@b!Fd+upc{XKb05{C4roCGBY{AA{OvAv4#kNYyw&Yq&eI5 z0PYmL(m}4EYVOf%(_{R>*+T^jvcE=nhFw^2d+i50%>wXw)t_7s-#IMDafJ9gV-zYQ z(qSs`<_q?CwAKwkS;5bRt}4oRKUcbiX4f(~dU;=w99L}Y+H7G07Yqm%>*z6B+EZ7=&m=+*bQp`Oh`s)4 zgc;X=3MF7Rk{B@3+*<306o)jUA&vQLA|K6FR8MqfV|r^8L4$D=gmKniUY^Eck5;U# z?vFquM-)c+%&>p69IMTY7F0O;6961OfF``!lE|i*AyL0{GBN4ohM!!IXl5&$YO1hh)voltVJW;hUi&?cm8gM%om31Df z*hJqiJ}5-YFnox|0}nOLB%i_n7=rKSieu;NLx=;V(Cl<3hcCPCNBBl#jXRQ+6x`** z?nPY1jZx*P<>BS37=|}m0m}uMM~DKMmCoyhTL+k!^gw2t6;^HyHLsB1_JLF=n<5wB zvzn$nx&aYEe>vPrhMj*A3w7vFvsKGTt%q3YL&yX>DU~@Ds!{h}QbtQ76|rcNS;5(! z{6Hu|=&@gY0&p*zV|oO1Q^fTku43OaJp7!h3nh7Uu4V;|1jQ+#*7N-WXS{+NwT`h? zXv7(y%^>iZHNJD(pd-b=9eo@@c|+j8?JG#WscHhvW=%EZ%9I4%&rR~58R)>G9UDF> zUd?8IseD5?aeRKerG`QG(5XvzeS%odomoOXZ>m>ZZ8CU~cK(CVm#n$dVl+(~_45mg zZb+z?@^AWhuSdk=ulY*2TbQ0Z0uD<)#dAe9^#JB-@Z4GYdR8w?3+3udtt>QX zhZS5srsc+~9W($^eoe-b<$9;@uDds+cMIy9iG^ut4h^BTl$AqfSuR{aNgk&fJDl%h zBjG<7y)cWu=g%+H}ZK$|4Pii zn$&$zD`r#vmijYWnFo|GxOSqk*o2RAP6YLWSKLx77xsrmd(?*Wbwmu~(m2r)KoO)} z5s;csd#Xy0weqZVGPQx@OlGC_$ah~G1Rw-IY~3+bE|pF`W6;9*iZ>-Y?)a9K6CI(nPj z#Dnuo7N0k4FFxERgjpr8b}q|mQpUWkL6ycNsV$yRU`ZMygXG*E3pJhDEM4tqY7p8f z33vG=t*wf+gVkNOmOl*LHO|(pdk0vk&iZ>0+pHdsaFm2Hn#cK;LL3qA!>KdZP@!qHG!KV>;(N>kzZk&y6;8iM#aE+kb3>+0?FM)k17@)SUKU)n ziN>&f>>7GuUdqs&#p%ktF2eYp?ZRkvKqeru?1qo<^%+TBo@AshVvjCP3cH0gDV~Y* zP@8NFSAqKXZ*M6938->AIeapss*6)7X5yBAey@d?^cEUGxq-W&dvd^k=e4z39i}o+ zVj?%F0U0|z;It@V54e~+NOiB)BHMWrq${gZmUc`VGgM139Kyn8CNMJY>J;Y%hn}DQ zU{p1Hn+zK^h4N0Fx*~~yGTmiHu;O0cE+Yt|{BOhXz@ejz0D3W5Ure`N32IA{eSeBx z6r@!GAMrdS!U#DF5?XFQ1rfEyJ_JGc7^}(VlTvDjX_Ze(gwn_W=R!S!ub(9EmEr9o z>BDY-?8#J{N0M0LL5pW{HgzMU$2K5#w8v_LO)|qHT(;M3I>*KF?XhB$(yk+RL>^9SC2l{E5Um5zXKKrTsx0&8TA5jP2G2+&pwfzB1hzxN-yyhRpLpg zR?tYJ+;x_jzpN1Hh!}QSi3zKy$6CjkhW@#wmM2cqY93_lEmP!_um_hMzsV&=Epl z6IwkvO3#uSlWL<_VrPzZLi3#LHG(4>heoRW77>Go`maM(EX}!@zT$iUkNI}5{roYy zjhW9+hBeVUV^(q(DOQS?n#E?Fu%J|^RLIfcLpfuW9Iw@cCOM|!jv6tGU4d~AS6Ai} zD4Io?Xd6rurp+eD6q(OXN-&Gcmdg`r3$B)m*(0n7mS-xxH z6B#kk5{f5wo}T=71+;&5wuG4vxuhzw@kv~bycXLMb89{8=7hAReAOA=Rzu0PSI9f` zRtek}pl=}{RBj_xY>b;@9>8ck!_{WVEwgA=G}DDC;@YAuND0p7@wVn{kLTwac8G!t z<8FW)s(u0T9-uE-9Z}B9lP&j5pcbiIg>Rl{AVq||BT0^_hKm_%*~=(>I=XC?q*dC6 z*}j81nCq8)$Y{-B$x#;-mzw3cN)3sq9N9$GtNx~!JNDW`{JWSC&N~F96Yn6G7&L75 zHCE=UMc(}|d0t3Ox*@w3MvZkI;!!0@LqGLC6R1Ss>Wp6mt2VHFYu2i|w;qP6;U1`1n9> z6kB}kQt9aMek^)vEK_Bf{~+Tl$?_y*de8Lanxejvr=lddYwAyAaEVZK`P%6p#*xQ0 z9A`OsK(a1T-!aC@w#$)DGSEJ@ye(NlDy@9m0gI%}d-BY14V_s@?FV|Rcc4%H;Y@dK za;jobL_~ysc$pm-|D(S5s55KE>q0Mssa%y)2QKIpC>qLdJH18fpssw9`AYQFKY9WD zgvz@7N_59nEYgI8)Ya@hTNe{`5oRn+Oa#e0&q!m^m4_lMPU-RIF2C>3rXVjGz&vF0 z+kOgv;kW8zur!rCqdwj39+UJ3NCj4nJS*u*X?M?U_L?fEq2rYNvaMzrXo&HUZm~5N zX%kU(zph3R5!j}v70b(~s~fMT0fbf-5M(`{wAR=|xs2Zdq&0X-mJhCvQ6^hg;xyis zv;E8+>9*JcGsXTqr!g0iFt%@}mFO4=jiJDgSpt5fTH5CZW-iGU4tjOrMR zgGhIvp7u;ux#nzbr*Zxew&3>P9bNb|kwKa<_4(7A5il-u&|v~AGZ zg>4fZ?NT~q@6w}PgbZWa&kiL&9X)Ox(&tiC+S5K8TH6wHoSHFOn4jzU{HCUoEfh;~ekYg4b*%(g?~PaZzK8z9@&5 z9^!Pm;j--%%x`bO*oT|8TZ|U<;@H~35?Mm(qMr4gu3{qg%Sq|pKaiL1#4t%r(#kVG zd+{vcMd|q$YMF=J5*YB@XhrW4A1&6b#Cvs=l#u~VH!_073$HH*Kj0EvpDk><{+yL)yE;gRn1WC!ytNVyqfE zgLcSrZ~B0TR8$5e|M8G0k8MkrB6J@?*wz0&cHts?=!CPqzE-ojepD-DQ0r#FtFXh^ z?+JGwBl$KM?P7k6VuM0_A6YXJ4p+BxTubSs6(Sn$`g;!CJ7(!lcVzI-Q=+xv$i4n{ z|9(e_=cN@tdj#8`=Kr5BTvUWOpxe#KeSo?^hK(scl%43*)KiCgp+BgU>1aa)@eb9P8vB3#bQSX1>@2^jN z^5N;l_R!Kjbdv=Hg*-NK2AB5pVul0oUHngXM)pz}&G%sc8>&oR`zg1>Rue;(V(zo* zulvFVx38pF{jPz|U*(iN13XQX*+sfXXGe$%ardJkg=zb+iEj+)$lC7ynnhv7%4)2H zP#wCD?|;DNRop%^|5`DWma;4D7XWOJeL6j+i)e4fcb^T?)$UUiRLN+z){5YgaYzqNM)aMuksD z{Nd@6ce+A*5h6MwkO)ZrQ@K*xY?wAA4V^tmL@+35pyz^bng~xv53AccWF< zhUv^o8~Z7rIN(}>eOjS;26vALp=i~o=Rf4dFLG^G1Ntq}xaqZv8K+IfN{zPBVa@$r z?=Fsmp?0^YwlJ*{Y~PPwtbBimrGS+hq9MC!i(|B!@L~(fzq06Weh0)p(C;eK_n*6h zJqCMUIYp}HnD@mCw@=dAojK~EwD;o|#bEnc+LPq_;@$-;v#USIrr9GxC~6QfIlM1| zyl)F+G>JIMgTOxSnuEPB`M}ri`@3ESII53P?0cmquG(1k4Pf5dANQ)m)3r?cD|@+; zXR!P4#{X%s?HPFuN)?Dmnz68DsadB$(9<*$rI40v=c5)$2CAZcdUe$~5J zjeQ-~cPz8p)7K&P*q0q%Tu87F;R=fD{|o2;FP#6GK7Ib53TK7{03^DeS2EdG@s9A# zQwa}KMU^AHYL3q+6_${&D9gSgxpHc={+Qkw}9FoO*}SZ_~&i> zm32q_K=wJg>E-V|9P?55CINNakKDna{mF0JL$ds};gl-BxD?BGg^!*=gzBoKs-1-+r8!C}C{EurV;uZx2e$2`7+fbLR&2 zz7FhrB3xG3t4zT?J!OtH1rK{m?C+-Nk2JHwbR)e;dXh9in;f6@uHQSW@8Yl@U0w)# z??Z*TS`pt*h7J{kP#zg3-2wSd{2|oycg<&a?ZQiNtk)~F_5}tx7UKch4{S2?j}}-Y znea`}EQ-=;7svON>Kh`xUpyF1X`n>YXJx}gwlQ6748d4AyL3Ul-4u(@IILwcER1S@ zO3v+QGo#%^B&IX-$(A%73htl`{9tHCOo+WW6JY+6bsU&TlKWocNPvIe7IH_QoPM zJy?2c(7Auu6DTuK8KeCMXpWq|%oki#@rn6Niy1WxvgG@TIL99sR{`h#u~Bu|+4RS% z$0w-eXj^OFpKz)`7m}Jw$wGqTXc&03@_2#FN?UqLYvRzb=TeQJ$GSz;+M&ZVj{%xw zb^7kAQ9js$B}{A^g<$yPBY%|vKmf0b%%1X@2S==>mdW~0e&Or8R$ygY(urfo7^vDX z;eZ9Y{up$E{%Au#Q##;4xsQpKt{{%En`mMXH7k1se(^n^3?~`McLeHmtIp! zBTkzECemO=A>H3A)RK}A!uDr@$QwM5sZcb#G*y_0sa(!NuiCQ6!X*5=uTmsM_ zs(OS|{c~Df@aZ83wjg>4@;d zBFjbYYi3gUR3sA^nMl=uVU13S1rTbz@)N$2{a3}lzq?sa>o=pmDx?nB`^;uY#v&vQc2lJp57e~ zmV1}riV2`gz3+e04ob?Ht~IVd>6xH=&xruvqHB{guD_|;n+LIOe->;HpebQgWl+#P zPRXgT?gf*<34%AJl$>2GDgyw!`#Sr(ME)vxsZ~!-UEXaNx!c(RmkXHsur!)>s**^0 z>28Yd1M5n3s$%53S~eiGeH8$Sg5B+BHr1uyr$tfLz=-V{RR==mbd4&YMHlj7kYEs% z{BvW!dlL|$`>s?jFe1LjflwV*b^Y?`KdLDv)HrPYkW{codQ9l)1As;bKyX#sB1+wW z^wWb%psqBZY<-ie*U)_WZ1?GxKxD%&BsrmsW=MD&1{AUSY&R|lj|h8hJ5Yfn*KPvc zq#J;de5&_@_BMf$j_2b=A8(o!;07Jv^AWCCf;Id@*hTQ(PNvDD?^*}4N62CqBm6H4>wBFr3+9l{F4x88R z^u`NEO?|T*#r77&v-2sXW+7LAP>Yj6!$;PK7#F;-3vAS%`B5AKs1LoumsrAvL$1hA z08x!HtfW_Vz;219!y;882@H-~WrH`zGt(2!%3YZ5?R@7iNjndqFmq<6ZrvTDJo(jE zN9T|LrpnCx4J+&7ri})!EMd1^OzaZ#%Ys~3=}ao7wmO0G-$gOIIK6W#8Fgr&?W6)G ziKp)nyuT2snn6Vac2zsYreKie1fWq(08w^zili!B?nY(uTYcX5VwScUuCB1C$AF2{ zZqT(fk;fhY-GFzO0IC%m)g!*A1Z5sAY$qexJ_E52h55)4qazAWW!|3We6P79P(@7b zZgdp5zZ??#Cpp2PBV7=V+kQyEkr_i~qbp90eS16lF0jA9cjHq_1K7VF`X0l&ogJkh zP`}mez;4zZWB-{NX<(HOjepC?MI*nvTPI)TyW>a=X$K=XXDTvQDNz<;))HmJOjP@6 zU%e8*dZ{lrKl!4}{&b@Ea9h`c6m;%7z;YB=Y}}(`K>#ocEShFXxOxV;yVsGF_m$M~ zasr?djd556aL3{92NQ61-JO-9X1-#k^CBUc8vEZCfB-@$kV@*;^u?Z@>qwUO(@21C z>PUbE_L~L+m?9>|9v~X`RvL&>UHnl7NRpcB?P4or;okw7f`diC7iU=;jFSXwbOAoAI9bFy)jYxd(eCDgwK*%0 z0$T-=PE;_(MEUr~;+|b$o6W_lCH$3$A=v1M5s1LHY6VKsf_6-#jBpx}?R4eCw$yQc zd@nnKL#+&F6eF@e5u^F4`*hMN_PGM8Hcp`)FPQhpyIxXyFM7Qgo!9~xV$08vvAt_h zWj`-4|94bP2{_CJkYI<8(U%Z2#sNr`DQC%$owY~@@b8{~eO#cA@zY)C(2)09YZbE{ zY0n)w#;}1O^rnzSwPzx-KLwnDls?Io&P;xQb8!dCi1S$R{off79rOVFalvG5sufj~ zo)E05wH}MNPL;MDHM#CKqnJA7BCbCZ0O?KXA$gAf_^|oU3D&f!jUcExfO<9x{F(eg z*TbZLXc!=O?_5?<3LlLiztn@-MF5GfzIX2Xm6r@UUngpccZ(W*JEGU4g5>T^lm9Cb zhPQ;_$;W}dyPn+F*ZmJ35ekg_ge|+i=jCf`FV>Rm zaL@`Su5%W#0KF$}SS+bdCSJ_EEsl0nC0E<6}1tzF$q=KyqQcVO6O9n!Sg_-P)FQ)=cMO=-K7#rG*>CZ=NWEqJeWz^ zg=n_94YXj6VH`;Ec0^H-&xb0;1Gk9eDrRW8v+W!D7t^|?TaS;knQ6V-__G{G<0z;+ zW{?xcbj5K@Fm8337RzM;-9WW=Y`#Z26QyJ~9`!<1Mgwd$S*ZoeG5{{$S_VpHYs?Bt zTSS~H5cRQ?S^`>}B^X+gPdP}=n&I+dQkjXUbo4mU7yeIyMA02#MI zX0|qNzSRcCs#Y46Ai|sBbLCp>-Ri}L-Q>ZIe7>c(U;wZOz5=RLowV2}kj}fGNHc6K z!fN3D&+9|56?q1k>r`AKm?A#`=q_Jn7sJ9BmH_QYZ_+}U==D@A*a?0AR10EZr4 zK4JvUfhoXER1yQzMiap&;N7Wv<@zF6p#nQt+nvi}F2Ez~z8@7T2H=wIi{{(QqfUzz z(=?=(CxC1HmPV>nj?N=budI#!(8B$UycfPmm8&xBB3>Cik2Wq@XB(nxS<~V=jCLWa zpqJ^|DsKEoWpCg)P;wew&0?*@y248>I`0d6Md4Z**bu9YTskxi#gerkn>d2G>N?wX z<2Y?V8F1hf8+7nnpYIo{e!8rW38LbH;fwdJPo3utyl_@rTO#PhLN?Hihvfnq{T$f= za)+`WBC4H>D54hMB$UKmxpj^ zf{ZYB9yMSGTREd}`3gv1QP@m2>2)3|7NAuG_g(U#p%m|tZ-0$D8y9)b1IP|g##l16cG+m>Rwqg(B6BM z2;V&8ef%Y>uiNH)zukt7T$;!7GF4oYDt)*HvQFn$-5JmNWyQ*j4S<(t#WdSRA+z^G zra8$CRH?1blTrBNY`Qc6o4G{o0g%r+{Hu}io6~qY{VpDOLU#Q0s0BdUw)GNfk0bY= zWWp0o_%4ytSn^CA2HZPZd=(RCJJpgzbGx@dW!MBN9Q#BCmZ$w-FFghLXJ$H%fB{hQ zPxP4thXX~fK>BOaK#J|w^RI!B1&}XBQbmwe(BK4h6&PWbr#5$}BESGP3>2yEWNPN> z7N`LAamqf)z8|3O=T`tW1R`8Loh3rUwLqFQiw1+`{&m1Y^w3ep;}hA`#^~^tx{V>WQC5aCEc@$O(_Yeq1!~8;Q|G zbaMyheNNDniUUe{GqU>Fj`uW+!ml%wF$Xwbs>VHRaqyO;dSP~DM)MNL5F~(3Ewl^e za-0p244#1-j$>`Qy{uud#PWqDFvSqjt%Bv|@<{**K1$nT8;$RvRd%#}9HhhwTnTPp zW;VLgxta5;i4;#Am?)}%Ry#2iUP{#6+ju; z6x8LT56`q$L5ZBY(e3uLr=`2cd$K+O_2?{CJG|Wf@;Xqjz4YeLP|<@U42ghMGCNR~ zPZV@>!T8z_SL>`_s!T@tF;$;{vs&CxvpvEfbAj{BPX*AR-3e^Z`?DVFm4jc2RNch9 z;ZvGCS7Mw@X>Vtqm}GVPJ43$rZXty1QpZ;KIW&RaazRq}jPW@|7sIhKh>^Cv*L z@mx~DZnjA+1!~HHg}H>F_S{Sd%N>DA_4TR*QZmz~i#v!DLpij;RCw44M$SMQ_94XJ zQ(bZaV3tuqj$%A+^PMTkaw{L*io(aAuGR^9-^X=AOJxAO^bFiIogw#lCYe}KemaeS z4)7jy_@P_40@`aE%mbD~2Gc}`uF*7&%OVeS&wnt zes*q?Xt-SztSj@JZr!8mWLV`tz%)nqa1y8wLNi~aKRR-qMa+(uDznFWp9Gv2IyFpz zZ1%kyN^nH4j``aFtx{uC%|p*2i2(BXmKu~UD0ut{cl$4&J;uj7BSt_2_HR}>2c{OK ze;ydjUps`14&eimow)%S%GoyoPU}0(aR4W_L@u^2(nAM8_Vbqho_?i^W#__BM_d2G zwH_QbL#8?b^rnN3((_zh5h}GdffYp~)uRJZ4CiKoqIHuULG$5xsg*`1=y7#JqX3R= z2Mj0#DEWsJ{v$a2U;bS=@zn)*qAqmgyImkyD%iyvVs0z2Jsg$}Vv=r&ttn>ua1MoRZ|t`#~_c&xJ12Y36woJqE4J@A$~|eYzuXl+q=y8^?CitqW0mQ zRG|BG2lr&^@6yDz4e6>NfP`#Dd|#!3^p-J{OuCmH9FY5 zMpYdDYVFjWzxlHc1E_e-BcofU?uSG;Tn*B(ri6fvB7Lz>JbR=iw9}BXYad@ww7_uR zr^9Fa;d#4VR&daKE*YRgmKDNDY+maZ8Q1r2V95~zSK6-yi!0$-ox{{~v}Li-7mmpR zIuITmCowTgb~&;QN}BYoxIhLa5x>#^O}-Ur$6344n4Z8&jDR zt|8eyMkr?2-MN|kkW298Or_{el+0R+9_y9qIOuZ}QxT)IOT1;rYE!dh^C6O$Q*qZ= zVdRN-i^4w7Y)t|P=bznn?wKofYUpr;=`;fU1S3EDkJ@(3%Lg;H|No{F$1UnU|MoQf z*P1_Iz6lWDbd?aNd&s|x1^~(SoBD&jms93N@P>~s_xsL}oz*A#`vjO7k4m_e;}XIr z%Tc#;o6?l^CUE{ilenXbS9737?RJq*F}u)tkJ;sG#lQiJQn|tMYGq7rzMvl@Q%cAe zV;6pV1^E0;1<>8qf28sYKnFQ;QV|qT+cg8~8K=s6tb3c1u2f}p&lXkFeq)*#F+vKd zhM{ux-c(R`pP=2huP+gnjBPB^Bacon=v~eUNjg!}9t4KGuJ$@d8ODbXvHm81rN*HN zD!?SKukV)*kJ(3sql>AIjIgNTOFdhkP4Jg*wZ@8mBDQqY z;7hKKgUxvr)x)*s4Y{6=-l*N%9C)sGyWswglaoa#dGh^>;K*r1ggj!~v36)GhNmqu zMkMQ@O+G0>W=qPQg3&3m+2O8&1DUi1kMsnWxcOq&jhAqU{alIbD{{bxI_dZhvSn3P zv-=t1Ky)%YHNf~HD;ulX5oaK{QWf*WtGU5eQwdE`p6ubJMA5!!xLvt!Q^b&rb{Zq_ z{rz)wjb_rzz?=_j7-2wJjYZ2(l?$D3njQG+*;#~02 zQ;|RQgv^?{OnF{mekgBVQqNMqrzf1WDQ~^jh8R$CLv?JPVd0#|7PuN%vCp|RZ?ey0mBoXX-L*r!2`CKYrh>EsP%!N&v_J2+s?tDi<-~Mf09BH z%#o1ZQ;t2R@sml1Sq7}XTq)jn9kfl~5*RJJ;%8Tc-4DLF1n14Kmc_JL4eTDDb(ngx zZ|rW=#3Hyh&VO~Pf@|TUL{mV=x_imUsOuWyGm=vUmaQOd1-Q$z&pD;HV&lE#gPT+B z;^9|&-KQ$FUPYXE^uua@1+l#{y*d^qW|C`lKQi(O*`w5i4|f!_UFRxa7{C>Wg?t{{ zmMZ=hT?SWZXf4|6DVv05~)M6CXfXywoRkJa*?EYVZhy-2OU``(i52 z*E*k!%PcPA73(=x`gbA$WGQqwa25-4LuPujbyBiy2(|K>dAj3fM}vVS?!Xe_I(FDi zOuN=R{ArPD>l`e@B)4T6Z^|5h4O}-#yaubt6Za5tSKi|$k|0-Fu&NRUoA~0Q0p2+i z6<;JFHVet%rophpDkCt1Wx!90x?ONF<8%=>&yC4QuBP4&vQazH)$2{`FPZ2Zd$V6O ztMd+CU#q3u5FpqScDoj`g62K=mEkXXa|x$Mi@nEb(#DJ&`v=Ms-1a)7X&5NG)}ezU z#$Ml{IAUf#I<;Aors2z$4k5Pkp}PCJ%-}l@ndTU^3H!9?({0`qXmRQmRO{)2u87~X z((faeKNVGBRM8d3buepxdi)%qTp3&Y%i&N_BZRArC@i;29#rf~@efX$DP8G046yNd zx>PF<(Q+!t{L(i$_K{jXOQ|A76V|&G>sB3>9hM6`{az<)5~}Le3D~4)6g2{eocR>K z1{~l6Iv>T1ra-DTV}htx?%)fS$K5Z#h~^fF*(|qGrz`!_|8uRy%U1TBaca}~#B_PV z_kx_$S?NIwUpF$%$CDnHWXprY?#MG+uld2iut>zgttRt`P7U6m4&h?lH$$3iPvx1f zUJmI{M37!Q8d@uWq=~vRpj%a{*lSuk`uJwVe(>_x^*Y`zCYcVtdCR$8@jWj~UU;jj zqKeg6$2_C{G=EqI?rDCsbuDt3t0r&ZD%5JpmN_3NCoNuv8LB;8p?$F-AU#+{L@N9? zX-eXtVo!c*MLPe&o=Et7J170#Ki?=Xh+kZf-qz_gn8;q&zFo`_t#_+et$B@^H^PyT zv%g39FEO`&=m6L8Nm(@9Yiu@hqFI8_Zz<Q>YznRYgEM9-j4> z^zVe-K)hW4)@I&xrj@>sIQbp$#MO1peuoUXd*wjKiBB;7VEcqf{_QW}Hk0=aq%pTu zl@qKI7Y))%XjujuR2mqitCxJZCZ=~z*~%dv-YZjucaI`SgQA0NtA0p!ul49^VQ6K1 zBJk%j3~z^ht`D9d!ryc`M$_ncky#Ep-GUQnyjB7F)wXMh$p78%f$v7&nmxd zJST+!I4|!j<~ZyDSmY;F@5dOaUz^+>U*}(L{nGwcnL>YHas*~nylgd0ix9CTyVosi z3o-@93sQ|Ls-EKzchXSse2L-1PTTb0Q|HN?CQF_z)Svr5K@R-f|CkPZeYz$JjM=xG(}zxg3KSA{zfDUGGZ0tp~HRZ9*yMNaEE*&w(6H9j0mHE7YBh&Xi3ckz-)#G~EuV@_#N(k}$ zd+De;xR(yq6vQ3C>JQ*T=lUz5+3Sl^!sT}*ALP^bRKXkQR}Q~LTxiLiNAB%`w8<=L z64~O=jNi<8{$!WXB@dvQW1_{u`KsQAY3Q}&c;GleEq4vX80dRz3a6PwFEF7_n+J9clR<92({$S9*K`nqA;PTRus zyqaC({<3E^BRe6V#!dTIH8yuLxmbC)N~{@qv2Sq%FV(d6D9h3I9 zv|AXWUP71Nbz3h67^;BrBC;YlkHX)Vg>zqi&GL$Y(Pm@d2U_$A+9$|H47GX*4tuV%qpBM+&S)~pCjRkItU#XNjyHcMvZOHINf>2fj zL@>r`7&(uQipS4_gv=pc(`w#%T;z<#VG(^-P-GUFEqL?~sLRiLwOQVS%6LajlUc+< z^0W*rwxZ+Nqcx-lE1|Kx`wI*^^FbA>;mneQ|0YCND--v=3GYl4_rnr2y)Ji@nVr=hH`!}FcQAzB+~VN9&4w?Y@s=Fgu6l_t&NZyKAIIuuQH@!h77GZZ&TA#Hp6krwD95cdr z$`l0T@NWS2U~=Pqs3@ILCAt41h1`+#`O@$Jd`ocOW@jwhexlJl{m7ayEq)@2{A!_c zV-*v;c1>w7#K5dh4yRLIX-bS}o#@~s1!=zCYole9A+AZ-X^<{}7FN#%sP?!SQtZ>} zk5qRo=NWltVuj_T5^joLn|EAEySubzGl#d8UP^nPjP_oT8N2_svRZ4a`75FZZ>Qml z^f|)#`{s=eh?0f2|EfFR{dV-^i)8_xYH(%q#Y@Irec0+yo<}wf_=i<;4zXj6H520* zE9^Hqb`(Q{1nd23HN%rFLT+E5t?+~Am++T4F5K9rdEXwjGO_%i8+NzZctzxoH&r zF$STWk=Bl35 z10xlhQF{YN&Xj^J3m`j{ZHuN)n+xjTg5YHy&ggvuHqX_g@$dV;;pDZxju|QOo&SBr z^Z`5cweP=?;xj+BA7|a07wY3W#K`yzgAlP+57#Aim-L)wkSuWQhT?JuIKZW9%yW5B zI9c3wvN=2)q(hNla@}mg`7><+bZaJCuy`;E0AP;WtKWtAn;FtRClTnaK?BZhU&jfX zD{oB0040Cm+|JNAUfM-i4bT2+wvm-)HCF4ioqVh*qmZGVmTeBDrv$}J8+a&BMg5~! zZaMiQs0|oFuNsuk3Uuh^u7FhC_o41i!FWH{vGx>YX+g^(P{ONrp6_pZH~C%%LjzT2 zsyF*lquaePs6=NgOWG6Ofei=WJFbTOAiJ`ilG^i3Iknvuvydf{wREFwbI~kairF$(H3gc<|4HwRmjVtQLbDs%$%Mz7TN=jhE?G zf5vN_b!)ek9i@kCTXe>V>>H>3zGZcv+Oks9H&Z!~tmM!*a?)v8p!0`SDT4e+ir4WX zR0-skW{CKnd*i2 zQ6!u#v~Ox+S!Rb>a@0@r6@kLO=53uQEOtLk?MZuDM5!m z%JL`e(bsolKuM*vcIxNoFCtka*!tbo27bsPYo60w z+af68vR?(j6^dsscX+%QTAs|r znl6^f9a$TEQ26Cx>a5YR<_cET@U!(nRsGVxOQNaxc(t2UxOr1^+mnO!nV_!E-skpx zglp85aIHMuYuTFl;bWYyw2M77GS!mxH>F-G|Iu=kgE#sc;UjUNl;zV+CBy0B{8IjG zj1)%&h*w;GO?xd>r)6+y2O}&X-EZ z<}ie`(NRB#W0J~Q8N4z2`p>{3NYHw6KK^6w*KPG3P&5VDWKe$w`BtYovYgcNG5OWS z5~g;sT2rSU{5C*l3TfcdSq`v@qnfEnw_=^KZ z>|+R6fJ{$Y@jr-s;8*@k0LNlpqVQh^@CoX0_4K0yzR7QYwU5_1@ao|0d8z`@-K1q| zmFR_w6Z#WZmZT)=t1Yy`)(#HfV@v88fxC-~|JhRx0N>h%qwplP@drl2)Wdx9$$lg+<2yk(W%;UFx@1iwq6@^{t`QwMO`h%l_H7rVd&(@ALlB z@ZsSSV)ya9!171c^DS_Adjh1;Qvp`g(wmQ}srEJRH6lVE)D`ok=9Aqrk3Z7>-;TzA z@YEhN`S`L)4;tNn01DCfO^~mTpRsHQRSGl;q2tK8UINY$dlfGJ;9fDjW#)U&M1lVC zszLGMemHLU6NB9DV4mB+Z5(jdU@98EdCYom8SCZ#YRnlHL~UeE0lDRu#A*n%e>Ggt zsYM=^4r{IBM+?FX*SAzJI7qwR9fNmhPmZAw&(9I(#IZ`4!} z4F}^H5y$xHXR2sQp;DTPr((n&N*xt5l8j>U(C32^=R*|~{;@m#IN={TicPpwz(+>7 z^u||{$v)KW(6Zulb2@U_mLb})0dIrA!eys;uCGi_kl3dNY!w)Yks)$!9qrnOmns!{ z?fY(eYGz(1+s~d^k8L6B>?*KarI>Gqm$y4L&IoBSb2Es(o5p`HwlzzS$RG_86a;tI zN&GjlD~Ns7UYX?DLW4+ji{W(!>D7{&BQ>6oTOwInw{J%Jt^K!S9{2+6n7G&fDgfS9 zIdT%f+UgV%KY>a0+0X}vfnQR@C&|*Sh+YMe$VYT@G=@qGcmxTa`TjB~M5fv# z*zT5j{IIMBgUD>d#%2|JV~N;sDQOnb^RUZ}u=|8yj+h?-{X1auxO8%OZolC}rOfQo zvA5Fgpkm=n`5|kAX+fZCDh)X|h&`L;cRt+JJt@64X1%(SlmGS22?E_km(Vb74Z+*( z*V2#W9*Xs3cN?_pEvg@NN;t7No%SJC;2EgIjK!3@NdsF`LcNEliX*H+EBovlZ0LrR zpRuQC&v1qtubNhb%k|8@W!L0EXt7}EOdw)^?^HNtXypKP7wm8)A^ zXJ?S28y@EpT0}HslBQ)ZNBF>Paf&uQ3Cl$fWYDP;hkj7&4nQD`ZZ@F!&D78QGCz=E z@CEjXh$z|}TiHh@etsC_T}EbL8RTk^FXto%HR>liaIyfqInIg>}pyl&_WwA zdBLhQ!#G#;?o~^REOyRHGI>kRY0Va6Q|1{qG^AZ zEghs=d-CgzwV=kh%L=ew<&BZiU^y~3qta>hfjhPz!;0I*uSlhbXG2c{ecvhrT@i_6 zH)Yqr$$H7oF49qkGo`(p{^nbj24~x`e$nC=PrKoZOV=*Kcgg9P_2&HQ8`PdQL+L;` zGahHTU0%Gq$8I3EC#|#fW|1o!H_0mB(3sU@om!Il+A}iqO*WIG<^Nh~>g@vVP=6}B zbhLIqPKNsbUA9x_(tvOC_s%Bq(KH{$+_Rf4&iO|UegB-M+sQ!8lt6sZ+V+&Rk_ zbHVf*BmSfGn1BQtJL|X+IrE`-cMRCXeW`aQ7{dHqDhL<~idU%)Ly3p(DpzFPeS676 zn0!Qh%$K_Ww%}1g&MVwpkGb5fomXDsd{3q)V|r4{Q#zB}>9l&H;Q@p{JG${C+Yh|G zAm*{ydIc6?ruYhqf3<$GnG-$z@0zTs{D0QOrScdV zE2)3}<9}1HP)fSdE*5YA3Xg~zfpMX&?ssob>Ws>(*WD9fS`7}$kTawjE zKX-Jc^4OqB^AO0>NWSo7Z!w_zYn%3WhsujH+%WIT#R#-3-A2wKMT(orQHIjzDHp@^ z9({oKX)<-n2E=4teHgTm<)&S!ev669d3!PkYoZmUTh_K- zh+i+Xty0fMrL#)v1sg7XLQ{4c7Da7XWg|XJN#^U9{-DP|I%z}op!wX-mQJ|t%aN<^ za?0*}S(wT=k8GYzM-^j%9h}&--qk^PMEi!eR*t^y()3v~QcO?w(|!uKmg#xv_ge`A zW-YM!!7C4g&V@fu)BPX{m$@yNAKn^U_P9op^gx{(%C#Eo9=~7VQUR}7grvl%XPgGN zh)Rudjp!g^Xxwlrq35I&fC>sWlvUA*MDmm&_2NdKwObNVwy;`?M<+4Q=hYBOw?`($ zV2w83T}W7*+};aW(t+Czhzwdnof$SOQ9%%%V%(FfZTFU9O`Emwp6QkDooHDLeiLZ?EUqrHe!n;&6K@K`^zLStWQ2I+p0Cl5bF~ z843oLK9Uli-ErHEOnDR{WlblaZA zndHb0t#Gcv<=aK75^tr%f33}*gBqI10t+z!T10Ysn(sr!7Gm1hndwtoyN;wz0;5%K;-U35E6sd{vUpj} zv8}w#(D-)xMd;3)wa`RpyBX2UssG{h<8e4~z>?yspVLm6OHV9a-%_$$6DN>#;wS9ZI3V8yz*F**OMpj5hxo^he92bE}8!|1R&;5t4h z)iuc4<`*n`Z4I$qZ+AI+ESXRoQ1L@<{gr@q$;Gxix)!#ZgYJTpJ+o$Rb;@;>lF5c+ zfLf=EXU9nz?~~0Q53;v(lo#p(RMBMHfakHGjpkw8u$SsE#UX;O@yMx`?~7?C#~m9k z-)K-c!8Ryr5hlY40oZFkCp2>IEt#Zxe?2 z!UHyl5A@F6;omcb`gxR2&NjZ7ER&0Pn3HZ4<17_t98fhRW?U=lKG&al&~)8#9fZ}; zoix*KY;j&E?Ol#S$2xoOr{d;Me>oE$#ns9nr!5H;7a3Dr;!1Sa zsf{Ary7i3g42xtxNo2`8kzj6aoR_)1^DRtlB40W7nkKG;;GV`k4Mwz|E$yqVBcbTI zFC&W9@n@D~rjHpdvLRxn_xg%gQ`}iAVMfzJlYwT)^7Pf7xTT4Q&N;EIYO@IJNYA}v zi`GLyo|yT`?~3wABwr77V?;1)?6ZZ2X-h$RincIEZ1ujeJQrh6B!|g5rU;utJnmt-rhnn^>e_(-n1_&h6i^$DpM%rXF5e(9;qvgCh zo`zm(V-VY_JgKEeM`^Qn?#>&pO{FZ4)Y$+0XZRUrvNE{e8C~3vg7u)EPV@{=!A}{y zxOBfq!0M_lhypt|i12IaiMPnkJ?+cXv7&GsaX4tmsfLYpqgXvG$u7rw9MyyCS_TNe zWWM-mQUhLem>4re8e}zru@%o@>BjYnA8E{*$r5Hrp4-mJh+$b!KrIj_Q&eliGC7Yd z=rQx?s_U}%C`rccpxd#dCW_rlLvda(hb`xA?LlXwoR7Kdq0uXy0yAUN<8BI zK9y&dWVe>4JTd63(z-nSDaZ5v7P0MGF|LwoIgrsH?xBsQZAd5o63hIL>i%Rf=mUIn z&Xss^(Cs;Q+96pUe%-B{=u68gQm6U3%RExw0VOOmKN85Z(dsJKbS*R?C<@F*n%*}2 zeA~dz+Op}#U8iJ!U;f@D`mVH|qsR}HhP^RQy`oDaF1Du&A0NGy&hzfIZsvwtO-1onk*AI^ zaLKOR518Dql!Rc^CoA$uruQ89xeRn2od6ulHgb1YlK-4^_elkef?UY##`FBxJoHnm z815$sPtP9;f(xyXQDwRk`1)+*{#c+TUC`k0lH@I`-KH$Kz?zWzZ1`++9xU?2X4pVV z4utF3^yvj%Qz^HozfR@;OiLaHS8%} zf>Ncs%fRL!AFw~V4bBM&xz04k6$E=OVZII9WLiU_FU&T4`OWpV(sBMlVV4;M z;Z5YMGZ}91LtV+hhC9a!kdQ>wHmXl(nD?#+^W-gc-oTJzPfhvf2UDcb4VyC^#t~!XG#zY{E_&$k2yzyE)5(zy-KqT)+aA_- zmp##QMP$dVS8G3q7K?*csbA{OXS^fVTiO{e8f}HSraaRDrZ2kLcZ-+3Q-@n zD$S;4gT>$4NH=Dwf_;Nixbvc3s(0xenO5t|gB2x1lTo=TM`UCMp-O^0pJX(-%pO*5 z(xpJBo?yqFVYMblA-d17i+s1b9m8t1bj;hmXz|35LJlq!O;KR?R;d+D_Ok8VC)626 zTK<<-me2pzS>o`g4os>qEX8Eq(QSR8Cmxl~q^Qp%4H;f9@SEUx5pFLgloPLMN&4=! zL*%BLbudG+7V-R5Wc|1oJ>mL7c_uZp>i&J?Hcb)OhOenwaJ2f!Gj*7GAX0XCqiAhSY z=bwt~%Zidz&Hl$i?fJl>Sw{ant!p;X%FvY_&40YhvYuTg08i>JAY1?QVhMu2A>@`p z>Kqv!or0$J2y7@KYPLl5EB_hv1E?8Z@pCeUko4+83OP9SOwVCeiMk`+14}3Or@r$5 z5IVhehNj(3SZSA9k%3t5chLs*DJQ1>_1Xnj=dHk|mjE~wZ$2P;`>SKh?iYEzcGCVd z$4pjp>Df%gPND`jOMP)e5VrTAsxrWvPc+#Q*R6(pqP#9)LGf4;5ll8=ozpcu)|n+! zIqnd=qC4aHS+khD->`#H99nv&z-A}^M7L4>aH}M3p(vc8D%BO2bU(qcvxA{@t`fiW zp=^+;n$5*PJT3#%K5S}GhX00UpKrM!2M#j=WF~$ESpn9iXRSomhIfz>1b+0cXvW{! zO#LM%Q&`T6vGK?-$+=~;o7BiUdwSu`HI)g(#hs<$k4%C=Thc;O0S~GP=Dpsqh*|ak zeOD|iOoLMFKOlP90E;sX_1i4Yf0Dbq7k%PmRr2tVD4-}~KuN%I-V3ER?MH886JR-v z-A`~EUtsX%cGyjDm2c=7CO_$;mtRI)} zBRjd#KMroz9{A!%8LnWEVFUk*b^tCB_^7-vy~}Uz%~<-a1d$;15*<)vOM;Oq2pXC= z*++Dsa#{n@cB=BdnA`Tpivg=WIP<>!ZnvaNUadtmzrm2o9(<AK|8P*-Xclrrtw=^|9(SFH%^9bJ9Gn)wflBLJ~4uv4%0EUNDGk%%(CkQF0zqWZ1ihlJ+q5LZ1i75 z!77LVMk&tvZ$z%HD4p33?j~M@R+Cm4OY1T|8X!V=|~Jn5ZWT=`;Ces|wMb%0f@ zr@EfBW+fbEm{=hrJEWkgjP&(xc{5yEe=_X5XF@cqJg94hh;miU!j~154n$NSq&a^|^A;(#8dn6j)f1h__Eg^8D z(uLHkAeY2OtlhwwA56{ugSV9Q@)T8Q>p)V@w3ib|Ivfs+&c*yY%lmwLSC}`|TxHQp`Mj$6!ZQ2OMpm6w{|Po4o`$Fnezl>H zpbwY2Ke0jh>3ZLb4UT!QM9q&RBMRLWC`6$*`|Gc$gN?&Wm#yW`jy_dD+!PR#zfF=C zv+DWrC@n^@-16srq%jOb8-V^^?Kya@F0ut~KWFxLn5TbL!M&2sE$PeZb!((PASaMH zQDWVvL^m)iFfhvT33lO3xdizL<7;WbK2?~nOI_0)&cTqvOaF9RKkI!{{!RICNLlQa z+mKVHsEWz!HP3!(v)*uwjsH^ z7wv$nMh|VKlw=0)6w-39V5lzbb6w#HyxH%U2Y6GnaoTukxn~k-;@_TYiwZC#I}X zCN{i#cU>e?Ltfot48b9aXa9EdO|9rgaTp?^bBmsAasl`4-ksbL8LN5Bz3_3yU^!jk64qS* zGN`--v2qree~yf*t?BUYN&KXkd|h6mH6KqbX8)+NzBDw5fGr>P1G|XLWItohX01VE zG_8k)6x}Y4FF89_Iwe$My#rUZfZD-7Se>Fg^O1VQ_TS(ECxh=MpJ219m(OB5pTH6; zL7k9Ic`^kFi4LUdv`(#Ouzh)m{3CJx~Aq##JB& z)4TWb>mq-GLy%o!WF5v~gLMc^a1npS%Bd17=K`s&!_;!V|JoDu#UHyzCWhEby2Ogg z31_rJUaBIyd*)0EV+eTzA2f!#}(p~TUU zK(tEEYzwdjl|@nwo&otwxHtKgq#WBRvdm;aTx-)$odBMHcJmjiXf`99Ji|&Gh~KOw z*FBXPFC-dgMa+@j7*_Ni&6&ex->9i{ZCsZo`>)iBRheLAq2=A6ZdNI|WK;4_(SZHc zoIOB#$ZsFKoG8_n!jXjf`MGUR$S0>08CHYV#Fhj=R0 zIrq8~PyeEBb63`tCTQ;+vaaOX5XLZj`2-y#TsTSBQU2+e!DLxV#BsVSr7R&|vv6caiM zerCB!S6L6EhZn30cBw$;W=CYKm8K3rp+GVw`5Tld`t!9^L!Wby=9y>A+h{{osabJL zLS89^kO=-*LFBI<>vCRDTiCpQUv6;vy}DQtw`Lg)5@tUYaKh{VPibcz7WLNkeFX(U2UMifC+>N~cJ7cZ`UF zA_4~89YaWWt03K-(w)Ny!@#?L;CZg+oZI`6>w3>0UT1XX7dzHod#$yjl}= zCNFsABA*AC4FcfD!2utP>3iN5J8M^K#R<0YV9tMS&kL?vqIxPpv zD#6z4^kc%owB}6==MvYR2p#M%9Tq#$T0d5rJ8XzGu+d#$Vx&%byUpPDdf?474%H6> z&FyKsh)0UApS>Y|RCC#FE#Vtbc6#*S%j>wO3a-IpXF|$e?2gY(khhOSmpySb+_BSU zbu_f(L5-lI!>Oa!1SP6sIdx{4Rd3w9sUe2Apnm;sjgIGyyUz34tXaJ2wYAnOv9#_# zl-kfenT`nOQeD34_~Fp#6n1sCzR`xUVy{#1*Ao5oXeySf37NrSQA)S@5=)}offAd1 zO{cArnvG{JEA!uq$2s^FlDj+tg_!s~- zp5$a_%|sO6n<_cOb~QSb&}L@$R7`nm1lNZ)v_lX!8p-tJ5(~L(%M4PFGz^ANka{T*hCY&I#SQGdVjvM zui#lE4$0{42#0Zyx+|oOj`3`(5OeKUJm_5yEy_u4|3n`ztAJXa&Jx1t%SXJwa`}7TgdeZ2{=0&v&e0sUKrHO%9GPl z<>lF`HNmMpzTKDYXk6|3OeWjU^ zD&(4(dL1e$n!Cjmc#>J+x&nSDUySD<>j7E6n*zJnr`m(ST16Kc+TG=Tzwxz2R)M z#QiY8^Z@MIKPo@e*sRANw&=);iLFXCkyO)Q&wS?LvAFSrIu_6~-;(gld~@-klwcoQ zt4Bs>&Ja3#^{8pHDGfVlKTXpOs|m`cb!$(Y4J6;LqU7&se_>ULem_4KtE!&&!gqD4 z4Q5*2%{1<^)cbfe@}MF?BX6AhGDgM(+is&|9vxN{YhqXLeIRJRs1Lj1W42lq4=`u; zT3LDwKs{@}l5*i_{45=~zTp@t7inzP83Vf1u|#JGMYUN-ep8+qtW1Xh;%c4iKxvIY zk?(?1ZwC;~Pzk>s%D7?|O$1dkvEK)Y6CJx{$Aqc1Jr2FoDBE9C{rW4Ow$cpAcE`YI?lu>FIxkl5aOMGD#o21r5kjw)qK9 z+aGvtb&c8YD|7laa>}{5lfoni+IFZ{xE_D4w~rAmu(|bYGRHD?t#dNMV`Y9%j(S+S z(DbaA?3&v=c}~~ReWI@N-96oB2-l*`v7DTr&AW!>8#xusv4=(xcu(iuzUs-pLt**l zw{ETML`t05^P7dk16_ZJYVI%xoc50cm)ueoTSt>@2?a=d$+A;YK$jNdZh)UbY&atpqx z!0Nlr%X+B3QEtU`w#aM*CNsVrPqO|X84QI9n7*9tuFyu$1}F9%1|-iuysIW*V| z$r_$sTv}tp#Rq{3l?FQrq>dH-oJ2*F%@6~-^E_=ipLiu4J%C&3r-f*Jrr{++TjKTQ zQa#IhM%Y>T^7P@7?vyfc#={6(*wntCx<7cM*P@_u@0%O@WIk)F2N=WWS2th8f{^CZ zK2R$cTk%}{iQPCRUS}te1{IX4TKbL25pA7%liBL?way9M?;%+!36TUWL!UZ}{j21a zbJUF5u4}G;u4pe+eIW~-CS$C$_OmD+bq>a(8fw_tDU1B}{hMrLYdvs)N0*3Oo3eTw zs<;iTidm>zoiF1J4{p76q`d7M)@JvbtL*_3M}_s^dSvTWd)H2z#GH4IB|%H{auL^Z zDh}lHEkAL?wwQ|#M`bh?mCxAKm+~rYy(=EQK3thzX%YM-h}u_ezG`h`MOKd@L(J8R zpsIHd)5 z8SiQ+Dh;OpsI;K~B5S>As-u;=h;J=47|}=Z z@qPVaEQe)*lcu1h^Jk=BtHD6>Aw5yWsU?JVP;ZfSZ0?AMD3CNdG%w9gzd)0+h06AT z`R*}=;1Lr(!9=N+TY8UhENXA{tPCR%Q7%JoW(9?Qw@x#p3X6lZ!^O121Wwom)36R4 zJk0R0;Bpqc5`9n6xl=1P0}T<_2lI`ZGu852t^zut(854&qRL8~VFp2Xk0(;Pgh=k5 zh+$jQZI8X}_dpMIz#aKF> zkPjanEKTiAEx`I%#9L_Qs#KJ5wom*#jcLh!QIa7!G-kuL=0E@&J;Y~umKg5b4try) zAda}P7l}4-x>j4Sap;)rR8;#mYU#oY@)t@UW zRXS7HB%ed0wVY9tg*M0$a{jqK#rhhDzwp(0PGjMzlt-vDDdmo$-es{GUec*#eWu2R z9we)wt7U=1zLfr%v$6{rA(ot6qhnl!>jxqCGu(QIM)XSB8#X<^o2IsOn{5=o4}B<* zQx;sErCzCQSvhr3^9&A-sLVx`S5v;kqv|N1!m)L8A(m{%dol zUD4ln_?TM5<-B=UDghkK((1zd*x+Od+V=T9s(oKnC(|J4GH`dX4iI z87OpLV0?uq3C#rz_UhXDrhAHgy?z6&+e<2&f#*R~>Ufq2l)MIZ&QLtkUMR41vX;KG zf2e=VOfJ&1s`MbDIt)L(0q63a#t)Oo+a|sDn(-Q_4Sc^b|E@Uj=9R{Q3Ej;2dH_)O zg;DM07%}W^(_SBIxNF!Fbm_WySch(!N4e7)i_@4(s{Q=>k|I352r+M<{^~J8dT0mC z8tMM*+TJ_Myp|wd;nf5VSt!Uz&NEuMmWJwhaI<~Q^&HITwknGk$$HA|KE2}gG(nlT zYw^D26Zxz#ihfRPQP#Aj)UW3&J_ zS)XLr+t4zt1Ab8E?b(UwHpm(O+_}64)JuOTWx2N^w9{K((&lW`1&)0huypG|oZHP( zA@AY>%R}=v*`jenT6-vsHeGfWH+4Zcj>9aIT9JG3=;+8E+M01{VLLli$5xx};!`L4 zhD9gU665p{hjAjqC-8gStF$XSUJ^0EYIY=HtsI|ik-?-7Jv}|2atpLaT2*sjtmz>> zfI?`{>Y;A&2*F}{-l=sB{*zp-KtFs?9RAbWH`lrJG!1t}GN!a2@o=ir;LGRp-kIAN z5gl4bUSqhymk0HZygtUvkJlAYvi&>{D2)1S+_c+SGxW!UTX%SjX z)+t&N-}jKeP&isi$bWl_gz3&2@s5XGi$(uS7``tb>VY;rZ`P)se5HPwWnaPj3)TBC z*w=_H@3mnnKc*=$<+MNeM%vb_wTdHU?}xN&yEmw1u}be6qkeh^Lnsss8(V{6MhBY^ zr;Id!CyvBSPT_s5Y{%J>VJnc&$*ZMxt8lhL+BFcvD>*MZ3zoojcxJxnd6(IwZWJ+^ zuPe|Fv%NoH>7#Sk)paNjFZI}pCTvw~cGP9}_8m&iQbtt!_PM~%B;*wS?Xc3Yr)Xpa zAmI1nvV2Gr?BTIrdqgf!LNytk?VlKxvv&lS_{qp}51sO}J38VIR(s}(izss{nbutz zF#NNSMydzcTfIKG6HpY=8X2}QXYuq==&NCs{!(VOekDea!*(}0W5f*Rj$ifV61NuT zwN!TJG`)xYYon4tg4A>UO$91P9wtkhLouOtHRE*3b>}o}$v(TfDF3Z`Oa&E7+^Tt- z*6(t^RHR_8#q){fWf>Z$s`iTN24r7^GJ*wi1o8I`0q>s#C51% zAnf4&^&!FB%>F4zThy>yUU2uubm&=irK#7~1K7Gvkt@S0-vX#LQkFh^_#oV&fZ&je z6ulO;6@~{Ozq`C~+(C}%QCJf7jQ>557(eu6FkFrijCzC0yR1sWS zI!;rPCMhd*Jy_ttqF~v3kipd1F>|(i_b377)yjkabl=j2U|W6#AAn->rY zw0%f!evrCeFqMz_Sr2<7IQI;XYa!P+kjfyXv={A~?cwIc{X}}v3@DYP;AlIQX-9(d z+;3HqCgKIx3NnULS~X-2O_SRr!C-94BSt(xe{U&|H)Y*K^AC*zi zzaqSbSys&H${ZeHD`Bx3%gn`#E*t)0EA+nq%|UVdYcmO4?bJOQo2S*A(Bm_F@TXn-Q?r3zG0_$_zDlDV|wx&qv)8ht=txJy(v+Vv~}h5KhGx;`)x7^t1C6rnQ|CgU}yEBL;HU=dZCM`-F$5#Oytk71|oTd+!@ zs-OAr!d<<~8q`kkwXIHa?XSY{$SF))X*+rf*P;P4Yt6Ix+8aAEX$fVSG%;CVb-*Yu zfHHk5Gg|KW2?Wj94x{x_=jBuYCA9rb&G&uVhO8c*7PM%L!Xa#K+_&L()#Ig%K|p|- zn=EY`$RHDxVNqPXz@3!`BOJ20W-+JJpP_`PvH6jnL^g0cfDiB{Vl?eL#k>Rv_?Fpt zWNOFS%2*r}C4R#R4!y zLP@oqYqRmow5mNT@u*e-Y-X&-t2WsGS6EBo%~=L;;y$D~Q-^<%G#^>1@Tg=*hjj$? znrQWWVYJVt4`F%DnZyxfCy<8AqI_XU{j8n69bnK~j4Y(Au%jGxSe=Wl zI|););Oh#--K;OHC>*R5ggZxd)|Pr+S^40>hAhBY)+t%5y_lBp;zkzsV)ep>z|?(Z zQ)i{b0^1-i@Vn5g@%6+J-o1m*IA!mO^5um(&l`ngjqIavWAX9n7mGG<68CFySL!V& zT6&(RM`Miu5daG!m$p)oOWfzC0z}r}Gp`UaE|Z*xKWg+i+uL)lF;=XngDZD1z5PLa z8`PMXaZ1D9isMHvnjM;2Wjyg1D`oEd96hX3f`%GJhqvwO$j{|Xebsq5p|M!~45RX} zrRdlmqe>a&f_=7nd|%P|Ha?{?`^BfU5gJaWN?O*-6QTy|?|si$5G{Fw^_c?8bM!W48j%ySi76XUkOlx*^0`k-nX&E7JD#pv(T~>s8 z&}l64NhV@n`^OJd23R=!(}I7HxWZq?wR%+dF1k#udhjp1MONi3(1H=={E?c!$E36H za)iYsR*IuXiG~yJygqKv@|};Es`rgXQq=IoewL84LSWNM zuc@Lca64tLPbjD}Bzq#>O#oRk1ArSy(E7!q0uR8uVE|ErsMGiE~3-jWI zGUv6cHn-m|X84NwTzC(tD-pT`w1OiqFJhhy7<0ZIwyM^m@vv_%FaThwY)v=@xte~{ z9RxY+U*d}5LiEytx&?qWn90m<`ylAC_a%wlVNsDtd9^F~t)_MD**h!s;>6d3mOvHD zr&3P?m{M<+#xR1a_c6})nqCHKw-^tpTK!j_LWJDuB`#RANyiKnFD*3dw^a#8TUxXB zC^k9b!Y=MFjmMrnJGI?hpjXlH7H5a0a#K7%UD>nz2kYGMXiJuMcj&=JfJ)^&njK9G zA9vuDSi!Dz~qst+b);|FZU ziwrXz%XIiIVm|Co$2UayNjrugI(AK`Z^j$xr}7mo;nEaT?_NVfaTPB?Sd!L~QtTFY zmR#x_A~<0rW!TKwD5rAgL+2C?J0jrTHXV;=Tt;w%oT%S|fb2_mY%|gmzK_4wkoWdzn7pz#&B5~n4DXhdlh=_)G0pihYv&)QS=1w)3pn!qD`L+-z1GuN=bNjt(}LPe*s6g zrPf`D<6OKFH>O{cu{S--r*YZ78N^{bD}YEI9ru`H4o^mP-A8J_s#&C~$}6( zz?$m*^%{=t3)IlUqSC4J{RI*IpDz`kQAQM3weLm3IOaF2wQY0<`V-+ohf`oq z4uw`32`s&`!YH{SQF6^Bva*HZ8kJ4()%!slqGWn-zbM_u~s zz0wykN>g!d)r^F@#fptv*JV0p%&PA6PMJw>mzDLNxqkLLii-Ilb_Zox(k^GIu=>18 zV5UImjLO&E>t$DEF^R#~D}-aKllM@T1?>btUVt+;yfC9AYjYKcP;YlXXHH*d=|E`l z;hbHa6{5FXVywi7!6fxa z>NVTq7jdh6W_?VznJx%u{picm5Mw;Im?fvnulLQL{g7xF6qD=%GK4;$?P#YZW*2~R ztHu5tD;3<)#iBi-)^(S0LybX~A&@teEoQXr&6) zQPZ9@-PI++@~oZ7n827d077VSEUR2iwE&{qn?NgH+QudqpxgvS&|$E;{ecRXd?u~3 z9<=lhZ8Cr^b>$f}KmQJ-yjw~NZS|7UW4J9x{sPpuU@X>CiM>>2y$qDfcy`Vt*MlB= z^2ju@*q_#)ga}06>12+Cs^)?xplWA z<=xx6b~WT&3^4XAREmVMD2#3K%B%0@yaJg%Jf{8Gw_$#Z4b+MQnt5Mk=(F^?83GQ3 z7$o&GlaqxWF31bHR7-4CG5rP49dc-~;!k`$ah z&9064PZJaqpJN}@t>}d=ZOLQfx{P5(KjYNK9uABNmAjqMU+^fgQq<@3a?zyalijHN z21UkbtOCN>*`iN2--d7ePwc6oLmfwOw2B}>|2eDVBouj>6?bKzUG`8`o+}LvFJI(0M$E~(K zj}qC|&CRIP)x^W?>V}b!78H7A*va|uo!aP%-~tMXksXiZa_g>JQf&UJjqFv0ht5CU z&W}5g>!qu#sva^a|0t-S14t+CDs+mTOE4A5Xx>5(jR&4-dVPKcL;Jl^*Vn>%%!qbn z()&F=lGY3rq3a~BF)(}s%a5adupHmQ84VDm~P!@ZU9s)Z3kS|l6fu| z>zIAFEW2?BozorECCT#VRf2>((?PbQki9l7AErEG zSR(VSH4G4D&qnZ_T~}RmSOao4zEhSY=Wa~#ObW^JwH7G$=I;8e`Cl3>?@DxeloIXc05O)hpQ3-w5O1A-uad1baU2H9@_pdKImCnoLM1?c;= z%Z(Pxs`qDDZ2_+Fr9hFgvO5GLR3z}5;b*Phw}n8a_i;d}ae|ylmWPBXui%QWkFXg2 zOMD`q40p06M`9Nis`AX|FzQ9-+WI^}CK0+%&y_QGmmCtYqrU+n8BNNqI0Y zTxtGzc$b+iI$BQ2W%uUXt3^M9qZht&u7r3+j>grW+hh6Q&T{b2TNUHe7TUqu zS^m42<=xFOTq3M$c?p6;_+`tcbp_0T!P?Y%}0&w~txuTXAvuA(td;fRCKNr49 z425R23A2RL;zfo>7G29)U^ILEgC5&`EA)!OoiG|7{99!nHOAR+dh7a3s18@8<3g919M8;^g}$uFmROL!6d|t%nCquW~+Q)D~ev=$Sd~~9cImvj*){^r|P@I_R9m0R!gP`>~x9? zgDD+EO8k7m{;jqvU2`>&?_k5iIJ`1t7NRf^NBw z2N-m7TQpzm0c*wj3y_yu!4MN7$p8$oOf5_4mc%=Xn*y%eLBQ5D0stabt>mb1EGxKB zk(SL{;aBG#oG0Gv@6XoKh;iTip4QE-()iuFgAS-=h*0qh(;0e^A4cDl7u=Gf(f(g4t)cE{$KB&{TN(|Vt`DR? zyJFLEx^jk)o|d$LsSkG#{d0+Y{Na*EMDu6qL)O000haGr->b6sBxp1$gBEkW34?1T zXg*ALc4_gR`GASCZg6j&N9!Xa9J(sh0ON1@z7sL(x3)1C$^{|IO5%m#B^}D_7l?0d z>vT&F4>^eG_Ao8(KXk=o|Tcs z^wi^FzO{<$6kYa_Lt0Lw4!0_tm$Hf@o+bhu$vMTB&xJINQ?>eXquf?Yy%xgosFlC; z-afp8M-wt0AaDsR_+r{FD_Y^)@Z8;JBYw+Dg&Dh-3nWl&wgXpjhrW&+)hOJc`>Y3q zxrVcx-YQ?E+KjB9{w#tanrE)ka;Xk%XB_IYBukG#HN2Lf5fly(eD<_pv@V-gVD%~# z2&WR7mgM=-;az9h;MaqHW2W!_Px^F9+hLx@aInyf$1TG636a30me zyYYoc7AXYVpP89CYmsub4q%od42W*YoWnO6f^l06-Qe}eEs7WQ^;0IY*C_M$b*%t` z(yi1lO4ocSITZmOYd8krXG#HywzVGc0vjLFN9(Jc9TnUe(1SRvVFm{i@p9xC;5bG& z(7LaCEtjuViJ&)6dC3s3`x2st%vkR#Mtf>f#*5YmDG7n&JH0c{K9e)W#Zp6szpq~P zCd;j;lk-3J0fZHxHuL(_8tTpb%Aks9l17nbFa;-mvbe5p9raLq{@&qV`-idDCfgRk zbkf&RiKZ6S01V`3G}x4eA;NjFMBxsO{y4wUl%XR!o5$;HjO`tN|BdIe%mtdEJ3}V# z3qMTkA(vKu#A!s|Qe`9=Iy}9MgS20qH0e$`Yq2JH4}did#wuJOe^gkZGh5ziT_M$^ zZy`>UlTYq&_W~tXi=thR2dI>0l8xko6_&R|^Qi>zF8-C@268d;@xId_*~7h5aA=lY zD!%QkE)w7)oN~TovL49Ng-tGgn%vtraGjBcGP0R{6x#n>cQfHW35<)+0p34_fbb$Ayk5E zin`_aWbG-QTA1}`!y>X(>+L5aC=Gk6Mn7wT&W}$~E1K#P{s9ae#CP5tbs8W1%5FU& zJoQ6|W?&+e&yuX0mR~d9@Ljl{OJ{C8Jqr_=IJ0!dyf$c(3qw)Ev7nSGFNw1mQv0Z>?gW`K$t=-i8 z?jReG?6fxKHC66Zcb1S)U|XTHCN2im5`MQ{ZsrPzNi}q?#|x4c3w{#5eN^v1K$}m>PirMTk~_24*;wC(()og3)t-qv2N1d^Q|d#w3u?U%j>psogzEzYJ|Mh3wNr?o%>s^S#0+*emXA81hxVd!c{iRH7vjLlT+gxN|6!f=LK&F2L** zR|f-L^@HUfRp`}^z>0)fZ0`UE_HuDqtXw`nAbq{|c&QN%-UTAOW>+7$+Tjepg63kuu@MG)XW23p8a< zXeG{HQ0IrMcCz#jtBSrn5WKIXYL8}K!vGSL287JZM}tAddR-#Gf1sD#C`y-MdqEud ze^D36*lNtzd~k&J*F`(iUx*euEcJZ{PQy(hAsq^COR9o5!@fRO-}?Ez=v%_(o=$7% z(0gU7{>EmeDY&cB&B+CDVX-6U!$90feXE5339~%l1_P()r7#?#25--IUI)&w@k||X zJ-I)((DDPIR-&3prK>|H{GbRhEy(X#$EhvTR>L)DpniJ;#Li_N1bUokR?!iiVF$H9 z%C!ER9BEGj4C6~%Bl$xS&r8c?zDLO_HC;kx<1;o1hfcXUB(V5%_@5b za!LM#Fui~nOkJQ&Yb+^_DwIVqng)x(rIpqd0`0nGxxWL3w=MzL`O6YLSO%1{dBq+8 zjbxu&4QPPTgnar08J1R4Y82qT(ZeXY?72e$L9r2FU#&u-9s1O4!Tt&-YYr+X4Pt`E z!iOZ<6B*C6zo4; zsmTGDa&}OzwTr4gw775UnD~~ZOcL7f(MAYQ02c5~3gMoV$$CIPm)jYK!v`zfis!G? z0TA8Or)Zn0Z{Gp489NZ!1}xSCY~LY{PpRExPK!McAxJBTeR4G;8zySK#YFvw%Ip`e z(Z{;yLF_yNp?&Lo8F&)NsVb2I$MimA*0373#>oZUOYR({AZ$(Jy18Ihrfg3l0}fm6 zwtv#DbPaC+;7ZQ^dK?iQ*M*x4{W-B}4bT*!8-x^Rn5E$V@aKQCxdMX=G^i!iL<}#g z7vphYh1cS7a0+i;zOb#r)l*fdqm?a78%QfOHX`3k2;4}(g>MW3rWpV|m^63b(Y&`xVrx68X z2H=D6!*Ir&#yv!IrL?lWq>((cVR|w*zbn+%zdBFaG-wpd%$sbRl=9^1lAUyFatc&m zlzt={skHatpT&5;w`E@T`1&A~?){uX(7*2o9^H_2JdD+6i7!U59(ifWHhT7F0N*Mb zF@fkxV()H&`nawyHHm4VT9H9Bi3{Lsx!H&+g+0XLWZ_BDJER8$Ib4JCjAd4Oj2x;jRY27@& zW)wr3m9B;6fQT5uWkC_pmGz>dF{=I)w5`6WQb$^g<5 znw5D`q$jy|umn^my~7TGwM}E3R|{&e*W{8MKnkec{4|VI+kN9JyWMQdYwTXOAJ+r< zuf3z-^edre1nY&DA5f!IeY8n+q(=G2S@Yp$5K*mYamM7&jVW0J(X(9(A048L6nZbV z;(O}9doGu{5A0GC;J~qhN~m-G<-mLFLa4AVjFuN08+@s&tE=H%g<3NF7U<#b>~m8m zlfQL7Ys=zHMlg}r%tC~bXoh*k!VlB{cJ`)HhT@%G*w*Hz?bMfh=RuFTfF9$4zu^%% z$vgmcNZ?q4r9r*5ivNV}J@SxaNn}X$khhTMmn7+b@9(1_k#rxr2EH9?)G0DO7+HmL zsc>Ptsc7L!3&-&zFsOseiRqjU2t3hfeuF*#{Px1Xd-j~_quPoR^qC+z%ZpbYdTYV@E>mRQKKkK|Ju4M9T=x+BT>PBQ$?r2&#x z49E#WlOzdJ+oj0mxpJakCOrV(mQr0fX8d|}fEN%g|9mpyZ=MAB?T#j+_>rLq!Un&! zo496+|A#I+dP+rG@Pfhncr^>hoep86T`PHho!I)r6>jK&Z*R&j9ILr`L2ASGU7wE- zdVickdC&`oX#V8`@SI?6rGy@Oir1Fp54SviXsB0$cNgtekG^d}3jB3PTY{5y@Iw;t zZGDBZBb1Ub-3MS_BzZHBKmFmUz09F?6{hm$&Qbhh+8_Kjs`OLTxs!BpurhezMYZbNo7eS}w`ZGNR$6FKCP{b^h)3<4{q{6A1YMPN$m z@n_~H{`9+}O9!0+H-0Fb1WZ5V<2?g1)?|n)!za1#bKw-AvD(tZSYLAVTU!Y1DyOY1 zcO<_3kEZ!g+v_ED6G(MG*0Pi%ff_e10cbWVZxc`aJlRu2@A%;C;Bhq1>mF!;Z(d(h zPWIdPpkwZC=CS>C)W@2Ta5q9FWa}h*_P9-G?(z3W9G!r^*mw%+HS0@cSbyv!sIhMV zEpxW3f{w?Nk4_N$c*7<4Xny>$m-Qi;@0Rjg{zs5cI$zLSv+G}Hdj7Lz|8uOS-2iXQ z`2E!^$IX^3~+ytj$cz@aD*CH%hFi@*+WSnRn2Z+!$d_36NEe#+x?lD23h z_5!i2?@%&5^LZq<6+x6T;9DnIj+S{flv?`&GO?yu+iB>1>CDbBh^||MWP6_FD6gF? zhqbDvrpy4FSxo$e6RfUug}7H zoSf&p@yuIdp3R~Cs_O}ieIv_hZubGe62JY|Uya#B=kBP#@V%;_`ts;egfq_A0*tQh zjMVMhw=YF&mx5h*`n4pTW0UcJw|bu3)7}gv!=V|dPeD8)&vO6ZG!g&y+jX*|LFIJ? zf7%qZg5yAnEG5*B_{0mp)7AL5Iq_P)JWyyB$^`OO7kEiCXfA`W=p1e0G2hJAOA`jP zQ$LG<;tYwxdM3ChyZfIX)~g3^Aj9|T*RPKi6+e|>C&wGT@HG*hy?ETRs?arAG>T-v zCh`^-gTdfg2fJsEtk=IE?w`{I{Hy1sfjon^KxOtmDC)S{8p+*mO8UZA&1090|7dkA z!UK(9UHjr}o*T+iH)6)zO`Ki(1W#;_H?R=0wY3FpFc)~ur2<6%*S1gC7COW!xA!J?3>QqmT6^Z45jp(R04j`t*<9UTJP=qML+JWTWvj%=*AF7{jZH0j#Ha+AeLBf9fNs%PxWb{rjLf zoh4{Gn~%BJCuu7l$jLEcxk>-a%h?03pVH1d^xko8j9JHXH+eAg;m2EDWe!Wm<)3Lz z@F-p{!4+pMb0JP>^Omd`hQgo)`#a5ve75wRH`6<(LbG{=1ua5id|7<~G#<+M9OscG?H-)f&YAM>xI=5o-t8v2( zXENE>$X^#w5C;uI{-K1#9b{e-ChHRtt}XPWe*{@2iN0(t${t@FLJ(2=3%S%!sJ<$w zT4tXP8Gfck5az}s9zwdl1Gy?(wLJYdJRw@y#hj)+L9?xqzA%QeVcXv&eE(&y=_;|* zksZoRR2;?%nz*rSx-SL=gX2X7z?QteZYiO&?uWnFWSX&Q9cf_JMC71bM<(;h3 zWsWNhFba-yr3awkmv~f#k&FNYG8H*`V4OW^v=5}Er0%a8krtT`J{~GC(WA9J!4@d! zDi+A~6BFu1vkYcAl36{yGu90<)H!6sM|*9$=g-4Q27CzN2fIdU<(?lOnGBWOLpXFH zYa4M0nL}{SQ*xT>Gmxx~ls?(6p8!qKx>#3te-M6%0i`nKfqU@1h1A)}F}F2lAPZN7 z01D&=?C^vAngeF_f*`O^M+(}z8fJY+n|xpaok0a-A{@Pc0?)AV!s#sMv`JBmTt!xU zQQuti*m<>-B{cFu1G&W^F`_SAcE?gdB||a&S(QqAbMtw3iP$+0p2KS6=>IVl&xk(9 zi&oxrCW{O&52oa2VgWLSBAAJ1k!5n>>|#Y$OD)thJ7CMXi z#qYg#0$>H_uLFOZvNY$g3A-8?4UKip>BpefiCy3TBxdrS)?(%J_t-aED%Cq9K#W_I z+Pes3d8AV1qFX#pKpZ`%A0o{3gk{*G>(N5T;zj;~%g2Qhz#VSEQsnm!XPEZk0->(5@Y}wWg|H7hU6GY7P){w&i=IKxp4Sg+`Bsd4u)WmD&R14h%kb2b^Xpv z{_F49<>}NpMT;4JfCNpKBd`(O;;mlC-)W=<2KkMaG$ntgPIZ<2+L(a8(W6ng z!nYghrEHqk3jLU$Q-KMX`GOW9%=X9Sp+adXX=(2OQp>}*cg`o-!s=qJq8e5|HCxWJ zMfVaAznIW{B|v?T?RZ5RWCUwYu34z@x;5CbKsEArUjr{p>MaTL3!DNEwBs%iw|G$e zw(lH`IMK5s{K3Ea^`9RVHn1!kJA4S~Wx}4~gsgVN(|nqUkDtT+wf-GbwzBeuZ&C1T z0%?I3;I7$^fFNu-$OTQ%@xI%fi-lWeTwSb-;53s0MYvEXA2_+#hvwFRn(#b;?~i6t zSp2+9tl(&iD{%V-I^h)JJsPlUDdm^=Ws2`L{0cAOLhGXAB_bG&=OG4_2ShdQz7{3m zd9b;;iG^(|ZQh@y(eGYk-@oL;JCi5ZoB3`cUU%Z}clhrnIcNhkb*>35*>H2U&52BLF5CfTKSI>N$kKD4V4` z>q7$shSyQO$^Y5M`o7Q6(bi%zx#q#=Xu#3qUtTC36pOp_?HPiIzcFH)H z#gHWEW$%DoSEOk;r&)*^xEY@bs37Us1E|18kXdFN3giOyVEp`lHc9N-!u23W2m+n`7=^haO0+6MuWB}9>Y^0hjt}eZqNdfRbZ|9dF^u=uq!F-&pbV= z%*QI!LYOsffqj)15)hZKjGQ3q{u+wn`YV|K-9FX86WQ-g;745sN@|>0bZoh4qdjDli2E)=NSG-n{`1CE31_WUJV9j&zBOYonQ zzBQkk$};2Pgw=~JE%x@dyX76;Um+!x4rS3gnIWUQ_!f*J)lUO|R}j4b;~2*#X{B~& zWq78UwVI>BtT!WgzS`IVB&7578VJCyFGCTbEDHv9!b+X%pSL}y&s>9Exv^gA*LTl> zSQ6VOsl`wrkR)Z;9%Dg})Jssfc=ZUm_pinN-ygl~A)Mnsr=4<(Skzskfbjcjfu8HN zxYFPZ_16n86HnkSDw={Z9lW<`w|U%gpX9+H*d4h%x&*RfL149&0{xK;mZvw44F@Oa z+jF0!!39bO29CjdUQN)R2-6P-O~($>q;?Y^KlEJ?z5MY&#QMQWT8__5Lant#IJtw=O)_?#s0F6;+JP|CTGUv{I>0K7t}y93XOGPT!0 z@A@y-tN%Sx0K1Xx4F21Ht$)A$_^+_pMadK}u=P#5PLxj^gob2!HtBIhLYVIQ8Cy>0 z65u94SIk! zCNVLw+d|q5grL=e%eLPgE!fZH=~VtFm}OTaQ@mV0&wql#gtToe#|VR0#=y*6xpGCn z5U;jAv&#L#kqbvR40S|Wzb>dGdi?nD{oZMQ0&FZe7Qv*&-v^dRy7%CTi z{CG4eF|olK-mbXZdIxUZFRtq2+Y00_d>7}G1$!DOgfo>8A&NQL(H9e5{FVjruSsYt zCtU9X)lq=*#MdyY#fP(xlSSP+|Ca|L=`C!4N5fzM&JO|KR2$SnbkQ|m{nurF`>~Pf z?-+AZTpV3KPRPqnun1sX(>k6u7ZH$jfjSY4Zc)e1e7H!~adqT2P$MZql>AP-^51R< zozWR+o`P)?kU@jpWqitlL$LDonzQlEUq7dVVFllD4#QCIII%x*pgpWPEOe_ojoH&ZF}ZS4sbGO+PD2kh+sjsXKBEEVE;lBq3Brv>dH zZnoMSxBfaX;F}!(Zl>Q_Juh=82DYLSv~jf4)ei=+(OV{88y zvcXDu+b7s?gAu*}wGN9(z;WdAIY{OQrT&sv{6k~wmxG5iuYTcw6l?f*MDV}6;z!f! zBBZ`|?h48AG8)7H8a@3(@`+;W7bPLKvi(Cls^jmuk~(YVh?si;$4icl0Qm Date: Sun, 21 Sep 2025 20:31:45 -0700 Subject: [PATCH 22/44] Re-ordered sidebars in docs --- docs/my-website/sidebars.js | 352 +++++++++++++++++++----------------- 1 file changed, 190 insertions(+), 162 deletions(-) diff --git a/docs/my-website/sidebars.js b/docs/my-website/sidebars.js index fe6dbc27290..3750915b191 100644 --- a/docs/my-website/sidebars.js +++ b/docs/my-website/sidebars.js @@ -57,31 +57,31 @@ const sidebars = { type: "category", label: "Alerting & Monitoring", items: [ - "proxy/prometheus", "proxy/alerting", - "proxy/pagerduty" - ].sort() + "proxy/pagerduty", + "proxy/prometheus" + ] }, { type: "category", label: "[Beta] Prompt Management", items: [ - "proxy/prompt_management", + "proxy/custom_prompt_management", "proxy/native_litellm_prompt", - "proxy/custom_prompt_management" - ].sort() + "proxy/prompt_management" + ] }, { type: "category", label: "AI Tools (OpenWebUI, Claude Code, etc.)", items: [ - "tutorials/openweb_ui", - "tutorials/openai_codex", - "tutorials/litellm_gemini_cli", - "tutorials/litellm_qwen_code_cli", - "tutorials/github_copilot_integration", "tutorials/claude_responses_api", "tutorials/cost_tracking_coding", + "tutorials/github_copilot_integration", + "tutorials/litellm_gemini_cli", + "tutorials/litellm_qwen_code_cli", + "tutorials/openai_codex", + "tutorials/openweb_ui" ] }, @@ -111,41 +111,63 @@ const sidebars = { label: "Setup & Deployment", items: [ "proxy/quick_start", - "proxy/deploy", - "proxy/prod", "proxy/cli", - "proxy/release_cycle", - "proxy/model_management", - "proxy/health", "proxy/debugging", + "proxy/deploy", + "proxy/health", "proxy/master_key_rotations", + "proxy/model_management", + "proxy/prod", + "proxy/release_cycle", ], }, "proxy/demo", + { + type: "category", + label: "Admin UI", + items: [ + "proxy/admin_ui_sso", + "proxy/custom_root_ui", + "proxy/custom_sso", + "proxy/model_hub", + "proxy/public_teams", + "proxy/self_serve", + "proxy/ui", + "proxy/ui/bulk_edit_users", + "proxy/ui_credentials", + "tutorials/scim_litellm", + { + type: "category", + label: "UI Logs", + items: [ + "proxy/ui_logs", + "proxy/ui_logs_sessions" + ] + } + ], + }, { type: "category", label: "Architecture", - items: ["proxy/architecture", "proxy/control_plane_and_data_plane", "proxy/db_info", "proxy/db_deadlocks", "router_architecture", "proxy/user_management_heirarchy", "proxy/jwt_auth_arch", "proxy/image_handling", "proxy/spend_logs_deletion"], + items: [ + "proxy/architecture", + "proxy/control_plane_and_data_plane", + "proxy/db_deadlocks", + "proxy/db_info", + "proxy/image_handling", + "proxy/jwt_auth_arch", + "proxy/spend_logs_deletion", + "proxy/user_management_heirarchy", + "router_architecture" + ], }, { type: "link", label: "All Endpoints (Swagger)", href: "https://litellm-api.up.railway.app/", }, - "proxy/enterprise", - "proxy/management_cli", - { - type: "category", - label: "Making LLM Requests", - items: [ - "proxy/user_keys", - "proxy/clientside_auth", - "proxy/request_headers", - "proxy/response_headers", - "proxy/forward_client_headers", - "proxy/model_discovery", - ], - }, + "proxy/enterprise", + "proxy/management_cli", { type: "category", label: "Authentication", @@ -163,45 +185,25 @@ const sidebars = { }, { type: "category", - label: "Model Access", + label: "Budgets + Rate Limits", items: [ - "proxy/model_access", - "proxy/team_model_add" - ] - }, - { - type: "category", - label: "Admin UI", - items: [ - "proxy/ui", - "proxy/admin_ui_sso", - "proxy/custom_root_ui", - "proxy/model_hub", - "proxy/self_serve", - "proxy/public_teams", - "tutorials/scim_litellm", - "proxy/custom_sso", - "proxy/ui_credentials", - "proxy/ui/bulk_edit_users", - { - type: "category", - label: "UI Logs", - items: [ - "proxy/ui_logs", - "proxy/ui_logs_sessions" - ] - } + "proxy/customers", + "proxy/dynamic_rate_limit", + "proxy/rate_limit_tiers", + "proxy/team_budgets", + "proxy/temporary_budget_increase", + "proxy/users" ], }, + "proxy/caching", { type: "category", - label: "Spend Tracking", - items: ["proxy/cost_tracking", "proxy/custom_pricing", "proxy/billing",], - }, - { - type: "category", - label: "Budgets + Rate Limits", - items: ["proxy/users", "proxy/temporary_budget_increase", "proxy/rate_limit_tiers", "proxy/team_budgets", "proxy/dynamic_rate_limit", "proxy/customers"], + label: "Create Custom Plugins", + description: "Modify requests, responses, and more", + items: [ + "proxy/call_hooks", + "proxy/rules", + ] }, { type: "link", @@ -212,13 +214,32 @@ const sidebars = { type: "category", label: "Logging, Alerting, Metrics", items: [ + "proxy/dynamic_logging", "proxy/logging", "proxy/logging_spec", - "proxy/team_logging", - "proxy/dynamic_logging" + "proxy/team_logging" ], }, - + { + type: "category", + label: "Making LLM Requests", + items: [ + "proxy/user_keys", + "proxy/clientside_auth", + "proxy/request_headers", + "proxy/response_headers", + "proxy/forward_client_headers", + "proxy/model_discovery", + ], + }, + { + type: "category", + label: "Model Access", + items: [ + "proxy/model_access", + "proxy/team_model_add" + ] + }, { type: "category", label: "Secret Managers", @@ -229,14 +250,13 @@ const sidebars = { }, { type: "category", - label: "Create Custom Plugins", - description: "Modify requests, responses, and more", + label: "Spend Tracking", items: [ - "proxy/call_hooks", - "proxy/rules", - ] + "proxy/billing", + "proxy/cost_tracking", + "proxy/custom_pricing" + ], }, - "proxy/caching", ] }, { @@ -250,6 +270,23 @@ const sidebars = { slug: "/supported_endpoints", }, items: [ + "assistants", + { + type: "category", + label: "/audio", + items: [ + "audio_transcription", + "text_to_speech", + ] + }, + { + type: "category", + label: "/batches", + items: [ + "batches", + "proxy/managed_batches", + ] + }, { type: "category", label: "/chat/completions", @@ -266,57 +303,8 @@ const sidebars = { "completion/http_handler_config", ], }, - "response_api", "text_completion", "embedding/supported_embedding", - "anthropic_unified", - "mcp", - "generateContent", - { - type: "category", - label: "/images", - items: [ - "image_generation", - "image_edits", - "image_variations", - ] - }, - { - type: "category", - label: "/audio", - "items": [ - "audio_transcription", - "text_to_speech", - ] - }, - { - type: "category", - label: "/vector_stores", - items: [ - "vector_stores/search", - ] - }, - { - type: "category", - label: "Pass-through Endpoints (Anthropic SDK, etc.)", - items: [ - "pass_through/intro", - "pass_through/vertex_ai", - "pass_through/google_ai_studio", - "pass_through/cohere", - "pass_through/vllm", - "pass_through/mistral", - "pass_through/openai_passthrough", - "pass_through/anthropic_completion", - "pass_through/bedrock", - "pass_through/assembly_ai", - "pass_through/langfuse", - "proxy/pass_through", - ], - }, - "rerank", - "assistants", - { type: "category", label: "/files", @@ -325,15 +313,6 @@ const sidebars = { "proxy/litellm_managed_files", ], }, - { - type: "category", - label: "/batches", - items: [ - "batches", - "proxy/managed_batches", - ] - }, - "realtime", { type: "category", label: "/fine_tuning", @@ -342,8 +321,48 @@ const sidebars = { "proxy/managed_finetuning", ] }, + "generateContent", + "apply_guardrail", + { + type: "category", + label: "/images", + items: [ + "image_edits", + "image_generation", + "image_variations", + ] + }, + "mcp", "moderation", - "apply_guardrail", + { + type: "category", + label: "Pass-through Endpoints (Anthropic SDK, etc.)", + items: [ + "pass_through/intro", + "pass_through/anthropic_completion", + "pass_through/assembly_ai", + "pass_through/bedrock", + "pass_through/cohere", + "pass_through/google_ai_studio", + "pass_through/langfuse", + "pass_through/mistral", + "pass_through/openai_passthrough", + "pass_through/vertex_ai", + "pass_through/vllm", + "proxy/pass_through" + ] + }, + "realtime", + "rerank", + "response_api", + "anthropic_unified", + { + type: "category", + label: "/vector_stores", + items: [ + "vector_stores/search", + ] + }, ], }, { @@ -499,33 +518,32 @@ const sidebars = { type: "category", label: "Guides", items: [ - "exception_mapping", + "completion/audio", + "completion/batching", + "completion/computer_use", + "completion/document_understanding", + "completion/drop_params", + "completion/function_call", + "completion/image_generation_chat", + "completion/json_mode", + "completion/knowledgebase", + "completion/message_trimming", + "completion/model_alias", + "completion/mock_requests", + "completion/predict_outputs", + "completion/prefix", + "completion/prompt_caching", + "completion/prompt_formatting", + "completion/reliable_completions", + "completion/stream", "completion/provider_specific_params", + "completion/vision", + "completion/web_search", + "exception_mapping", "guides/finetuned_models", "guides/security_settings", - "completion/audio", - "completion/image_generation_chat", - "completion/web_search", - "completion/document_understanding", - "completion/vision", - "completion/json_mode", - "reasoning_content", - "completion/computer_use", - "completion/prompt_caching", - "completion/predict_outputs", - "completion/knowledgebase", - "completion/prefix", - "completion/drop_params", - "completion/prompt_formatting", - "completion/stream", - "completion/message_trimming", - "completion/function_call", - "completion/model_alias", - "completion/batching", - "completion/mock_requests", - "completion/reliable_completions", "proxy/veo_video_generation", - + "reasoning_content" ] }, @@ -538,25 +556,35 @@ const sidebars = { description: "Learn how to load balance, route, and set fallbacks for your LLM requests", slug: "/routing-load-balancing", }, - items: ["routing", "scheduler", "proxy/load_balancing", "proxy/reliability", "proxy/timeout", "proxy/auto_routing", "proxy/tag_routing", "proxy/provider_budget_routing", "wildcard_routing"], + items: [ + "routing", + "scheduler", + "proxy/auto_routing", + "proxy/load_balancing", + "proxy/provider_budget_routing", + "proxy/reliability", + "proxy/tag_routing", + "proxy/timeout", + "wildcard_routing" + ], }, { type: "category", label: "LiteLLM Python SDK", items: [ "set_keys", - "completion/token_usage", - "sdk_custom_pricing", - "embedding/async_embedding", - "embedding/moderation", "budget_manager", "caching/all_caches", + "completion/token_usage", + "embedding/async_embedding", + "embedding/moderation", "migration", + "sdk_custom_pricing", { type: "category", label: "LangChain, LlamaIndex, Instructor Integration", items: ["langchain/langchain", "tutorials/instructor"], - }, + } ], }, From f266fa26601eaafe88491e45cd60c64ebcd4a414 Mon Sep 17 00:00:00 2001 From: berri-teddy Date: Sun, 21 Sep 2025 15:08:44 -0700 Subject: [PATCH 23/44] Update docs to explainb bridging between endpoints and mode From 4142ddc8bb02354834ec5a881141447c6e0b419c Mon Sep 17 00:00:00 2001 From: berri-teddy Date: Sun, 21 Sep 2025 21:07:53 -0700 Subject: [PATCH 24/44] Revert "Ensure basic detection format proper" This reverts commit 5070f5dd4b575cf111179c663d2741b6ee86722e. Commit for a different branch, accidentally pulled in. --- git_model_armor.py | 0 .../model_armor/model_armor.py | 159 ++++++------------ test_model_armor.py | 0 3 files changed, 47 insertions(+), 112 deletions(-) delete mode 100644 git_model_armor.py delete mode 100644 test_model_armor.py diff --git a/git_model_armor.py b/git_model_armor.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/litellm/proxy/guardrails/guardrail_hooks/model_armor/model_armor.py b/litellm/proxy/guardrails/guardrail_hooks/model_armor/model_armor.py index 31d7d70d5f3..480be4a651b 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/model_armor/model_armor.py +++ b/litellm/proxy/guardrails/guardrail_hooks/model_armor/model_armor.py @@ -88,11 +88,11 @@ class ModelArmorGuardrail(CustomGuardrail, VertexBase): def _create_sanitize_request( self, content: str, source: Literal["user_prompt", "model_response"] ) -> dict: - """Create request body for Model Armor API with correct camelCase field names.""" + """Create request body for Model Armor API.""" if source == "user_prompt": - return {"userPromptData": {"text": content}} + return {"user_prompt_data": {"text": content}} else: - return {"modelResponseData": {"text": content}} + return {"model_response_data": {"text": content}} def _extract_content_from_response( self, response: Union[Any, ModelResponse] @@ -119,16 +119,11 @@ class ModelArmorGuardrail(CustomGuardrail, VertexBase): async def make_model_armor_request( self, - content: Optional[str] = None, - source: Literal["user_prompt", "model_response"] = "user_prompt", + content: str, + source: Literal["user_prompt", "model_response"], request_data: Optional[dict] = None, - file_bytes: Optional[bytes] = None, - file_type: Optional[str] = None, ) -> dict: - """ - Make request to Model Armor API. Supports both text and file prompt sanitization. - If file_bytes and file_type are provided, file prompt sanitization is performed. - """ + """Make request to Model Armor API.""" # Get access token using VertexBase auth access_token, resolved_project_id = await self._ensure_access_token_async( credentials=self.credentials, @@ -148,14 +143,7 @@ class ModelArmorGuardrail(CustomGuardrail, VertexBase): url = f"{endpoint}/v1/projects/{self.project_id}/locations/{self.location}/templates/{self.template_id}:sanitizeModelResponse" # Create request body - if file_bytes is not None and file_type is not None: - body = self.sanitize_file_prompt(file_bytes, file_type, source) - elif content is not None: - body = self._create_sanitize_request(content, source) - else: - raise ValueError( - "Either content or file_bytes and file_type must be provided." - ) + body = self._create_sanitize_request(content, source) # Set headers headers = { @@ -201,110 +189,57 @@ class ModelArmorGuardrail(CustomGuardrail, VertexBase): return await json_response return json_response - def sanitize_file_prompt( - self, file_bytes: bytes, file_type: str, source: str = "user_prompt" - ) -> dict: - """ - Helper to build the request body for file prompt sanitization for Model Armor. - file_type should be one of: PLAINTEXT_UTF8, PDF, WORD_DOCUMENT, EXCEL_DOCUMENT, POWERPOINT_DOCUMENT, TXT, CSV - Returns the request body dict. - """ - import base64 - - base64_data = base64.b64encode(file_bytes).decode("utf-8") - if source == "user_prompt": - return { - "userPromptData": { - "byteItem": {"byteDataType": file_type, "byteData": base64_data} - } - } - else: - return { - "modelResponseData": { - "byteItem": {"byteDataType": file_type, "byteData": base64_data} - } - } - def _should_block_content(self, armor_response: dict) -> bool: - """Check if Model Armor response indicates content should be blocked, including both inspectResult and deidentifyResult.""" + """Check if Model Armor response indicates content should be blocked.""" + # Check the sanitizationResult from Model Armor API sanitization_result = armor_response.get("sanitizationResult", {}) filter_results = sanitization_result.get("filterResults", {}) - # filterResults can be a dict (named keys) or a list (array of filter result dicts) - filter_result_items = [] - if isinstance(filter_results, dict): - filter_result_items = [filter_results] - elif isinstance(filter_results, list): - filter_result_items = filter_results + # Check blocking filters (these should cause the request to be blocked) + # RAI (Responsible AI) filters + rai_results = filter_results.get("rai", {}).get("raiFilterResult", {}) + if rai_results.get("matchState") == "MATCH_FOUND": + return True + + # Prompt injection and jailbreak filters + pi_jailbreak = filter_results.get("piAndJailbreakFilterResult", {}) + if pi_jailbreak.get("matchState") == "MATCH_FOUND": + return True + + # Malicious URI filters + malicious_uri = filter_results.get("maliciousUriFilterResult", {}) + if malicious_uri.get("matchState") == "MATCH_FOUND": + return True + + # CSAM filters + csam = filter_results.get("csamFilterFilterResult", {}) + if csam.get("matchState") == "MATCH_FOUND": + return True + + # Virus scan filters + virus_scan = filter_results.get("virusScanFilterResult", {}) + if virus_scan.get("matchState") == "MATCH_FOUND": + return True - for filt in filter_result_items: - # Check RAI, PI/Jailbreak, Malicious URI, CSAM, Virus scan as before - if filt.get("raiFilterResult", {}).get("matchState") == "MATCH_FOUND": - return True - if ( - filt.get("piAndJailbreakFilterResult", {}).get("matchState") - == "MATCH_FOUND" - ): - return True - if ( - filt.get("maliciousUriFilterResult", {}).get("matchState") - == "MATCH_FOUND" - ): - return True - if ( - filt.get("csamFilterFilterResult", {}).get("matchState") - == "MATCH_FOUND" - ): - return True - if filt.get("virusScanFilterResult", {}).get("matchState") == "MATCH_FOUND": - return True - # Check sdpFilterResult for both inspectResult and deidentifyResult - sdp = filt.get("sdpFilterResult") - if sdp: - if sdp.get("inspectResult", {}).get("matchState") == "MATCH_FOUND": - return True - if sdp.get("deidentifyResult", {}).get("matchState") == "MATCH_FOUND": - return True - # Fallback dict code removed; all cases handled above return False def _get_sanitized_content(self, armor_response: dict) -> Optional[str]: - """ - Get the sanitized content from a Model Armor response, if available. - Looks for sanitized text in deidentifyResult, and falls back to root-level fields if not found. - """ - result = armor_response.get("sanitizationResult", {}) - filter_results = result.get("filterResults", {}) + """Extract sanitized content from Model Armor response.""" + # Model Armor returns sanitized content in the sanitizationResult + sanitization_result = armor_response.get("sanitizationResult", {}) - # filterResults can be a dict (single filter) or a list (multiple filters) - filters = ( - [filter_results] - if isinstance(filter_results, dict) - else filter_results - if isinstance(filter_results, list) - else [] - ) + # Check for sdp structure (for deidentification) + filter_results = sanitization_result.get("filterResults", {}) + sdp = filter_results.get("sdp", {}).get("sdpFilterResult") - # Prefer sanitized text from deidentifyResult if present - for filter_entry in filters: - sdp = filter_entry.get("sdpFilterResult") - if sdp: - deid = sdp.get("deidentifyResult", {}) - sanitized = deid.get("data", {}).get("text", "") - # If Model Armor found something and returned a sanitized version, use it - if deid.get("matchState") == "MATCH_FOUND" and sanitized: - return sanitized + if sdp is not None: + # Model Armor returns sanitized text under deidentifyResult in sdp + deidentify_result = sdp.get("deidentifyResult", {}) + sanitized_text = deidentify_result.get("data", {}).get("text", "") + if deidentify_result.get("matchState") == "MATCH_FOUND" and sanitized_text: + return sanitized_text - # If no deidentifyResult, optionally check for inspectResult (rare, but could have findings) - for filter_entry in filters: - sdp = filter_entry.get("sdpFilterResult") - if sdp: - inspect = sdp.get("inspectResult", {}) - # If Model Armor flagged something but didn't sanitize, return None - if inspect.get("matchState") == "MATCH_FOUND": - return None - - # Fallback: if Model Armor put sanitized text at the root, use it + # Fallback to checking root level return armor_response.get("sanitizedText") or armor_response.get("text") def _process_response( diff --git a/test_model_armor.py b/test_model_armor.py deleted file mode 100644 index e69de29bb2d..00000000000 From ed4c2b6883be85c32486496a593da7251d09af7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E0=AE=AE=E0=AE=A9=E0=AF=8B=E0=AE=9C=E0=AF=8D=E0=AE=95?= =?UTF-8?q?=E0=AF=81=E0=AE=AE=E0=AE=BE=E0=AE=B0=E0=AF=8D=20=E0=AE=AA?= =?UTF-8?q?=E0=AE=B4=E0=AE=A9=E0=AE=BF=E0=AE=9A=E0=AF=8D=E0=AE=9A=E0=AE=BE?= =?UTF-8?q?=E0=AE=AE=E0=AE=BF?= Date: Mon, 22 Sep 2025 15:28:11 +0530 Subject: [PATCH 25/44] Update model references from gemini-pro to gemini-2.5-pro --- docs/my-website/docs/providers/vertex.md | 48 ++++++++++++------------ 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/docs/my-website/docs/providers/vertex.md b/docs/my-website/docs/providers/vertex.md index cb90b7434e7..ef407e6c102 100644 --- a/docs/my-website/docs/providers/vertex.md +++ b/docs/my-website/docs/providers/vertex.md @@ -45,7 +45,7 @@ vertex_credentials_json = json.dumps(vertex_credentials) ## COMPLETION CALL response = completion( - model="vertex_ai/gemini-pro", + model="vertex_ai/gemini-2.5-pro", messages=[{ "content": "Hello, how are you?","role": "user"}], vertex_credentials=vertex_credentials_json ) @@ -69,7 +69,7 @@ vertex_credentials_json = json.dumps(vertex_credentials) response = completion( - model="vertex_ai/gemini-pro", + model="vertex_ai/gemini-2.5-pro", messages=[{"content": "You are a good bot.","role": "system"}, {"content": "Hello, how are you?","role": "user"}], vertex_credentials=vertex_credentials_json ) @@ -189,7 +189,7 @@ print(json.loads(completion.choices[0].message.content)) 1. Add model to config.yaml ```yaml model_list: - - model_name: gemini-pro + - model_name: gemini-2.5-pro litellm_params: model: vertex_ai/gemini-1.5-pro vertex_project: "project-id" @@ -210,7 +210,7 @@ curl -X POST 'http://0.0.0.0:4000/chat/completions' \ -H 'Content-Type: application/json' \ -H 'Authorization: Bearer sk-1234' \ -D '{ - "model": "gemini-pro", + "model": "gemini-2.5-pro", "messages": [ {"role": "user", "content": "List 5 popular cookie recipes."} ], @@ -262,7 +262,7 @@ except JSONSchemaValidationError as e: 1. Add model to config.yaml ```yaml model_list: - - model_name: gemini-pro + - model_name: gemini-2.5-pro litellm_params: model: vertex_ai/gemini-1.5-pro vertex_project: "project-id" @@ -283,7 +283,7 @@ curl -X POST 'http://0.0.0.0:4000/chat/completions' \ -H 'Content-Type: application/json' \ -H 'Authorization: Bearer sk-1234' \ -D '{ - "model": "gemini-pro", + "model": "gemini-2.5-pro", "messages": [ {"role": "user", "content": "List 5 popular cookie recipes."} ], @@ -391,7 +391,7 @@ client = OpenAI( ) response = client.chat.completions.create( - model="gemini-pro", + model="gemini-2.5-pro", messages=[{"role": "user", "content": "Who won the world cup?"}], tools=[{"googleSearch": {}}], ) @@ -406,7 +406,7 @@ curl http://localhost:4000/v1/chat/completions \ -H "Content-Type: application/json" \ -H "Authorization: Bearer sk-1234" \ -d '{ - "model": "gemini-pro", + "model": "gemini-2.5-pro", "messages": [ {"role": "user", "content": "Who won the world cup?"} ], @@ -527,7 +527,7 @@ client = OpenAI( ) response = client.chat.completions.create( - model="gemini-pro", + model="gemini-2.5-pro", messages=[{"role": "user", "content": "Who won the world cup?"}], tools=[{"enterpriseWebSearch": {}}], ) @@ -542,7 +542,7 @@ curl http://localhost:4000/v1/chat/completions \ -H "Content-Type: application/json" \ -H "Authorization: Bearer sk-1234" \ -d '{ - "model": "gemini-pro", + "model": "gemini-2.5-pro", "messages": [ {"role": "user", "content": "Who won the world cup?"} ], @@ -835,7 +835,7 @@ import litellm litellm.vertex_project = "hardy-device-38811" # Your Project ID litellm.vertex_location = "us-central1" # proj location -response = litellm.completion(model="gemini-pro", messages=[{"role": "user", "content": "write code for saying hi from LiteLLM"}]) +response = litellm.completion(model="gemini-2.5-pro", messages=[{"role": "user", "content": "write code for saying hi from LiteLLM"}]) ``` ## Usage with LiteLLM Proxy Server @@ -876,9 +876,9 @@ Here's how to use Vertex AI with the LiteLLM Proxy Server vertex_location: "us-central1" # proj location model_list: - -model_name: team1-gemini-pro + -model_name: team1-gemini-2.5-pro litellm_params: - model: gemini-pro + model: gemini-2.5-pro ``` @@ -905,7 +905,7 @@ Here's how to use Vertex AI with the LiteLLM Proxy Server ) response = client.chat.completions.create( - model="team1-gemini-pro", + model="team1-gemini-2.5-pro", messages = [ { "role": "user", @@ -925,7 +925,7 @@ Here's how to use Vertex AI with the LiteLLM Proxy Server --header 'Authorization: Bearer sk-1234' \ --header 'Content-Type: application/json' \ --data '{ - "model": "team1-gemini-pro", + "model": "team1-gemini-2.5-pro", "messages": [ { "role": "user", @@ -975,7 +975,7 @@ vertex_credentials_json = json.dumps(vertex_credentials) response = completion( - model="vertex_ai/gemini-pro", + model="vertex_ai/gemini-2.5-pro", messages=[{"content": "You are a good bot.","role": "system"}, {"content": "Hello, how are you?","role": "user"}], vertex_credentials=vertex_credentials_json, vertex_project="my-special-project", @@ -1039,7 +1039,7 @@ In certain use-cases you may need to make calls to the models and pass [safety s ```python response = completion( - model="vertex_ai/gemini-pro", + model="vertex_ai/gemini-2.5-pro", messages=[{"role": "user", "content": "write code for saying hi from LiteLLM"}] safety_settings=[ { @@ -1153,7 +1153,7 @@ litellm.vertex_ai_safety_settings = [ }, ] response = completion( - model="vertex_ai/gemini-pro", + model="vertex_ai/gemini-2.5-pro", messages=[{"role": "user", "content": "write code for saying hi from LiteLLM"}] ) ``` @@ -1212,7 +1212,7 @@ litellm.vertex_location = "us-central1 # Your Location ## Gemini Pro | Model Name | Function Call | |------------------|--------------------------------------| -| gemini-pro | `completion('gemini-pro', messages)`, `completion('vertex_ai/gemini-pro', messages)` | +| gemini-2.5-pro | `completion('gemini-2.5-pro', messages)`, `completion('vertex_ai/gemini-2.5-pro', messages)` | ## Fine-tuned Models @@ -1307,7 +1307,7 @@ curl --location 'https://0.0.0.0:4000/v1/chat/completions' \ ## Gemini Pro Vision | Model Name | Function Call | |------------------|--------------------------------------| -| gemini-pro-vision | `completion('gemini-pro-vision', messages)`, `completion('vertex_ai/gemini-pro-vision', messages)`| +| gemini-2.5-pro-vision | `completion('gemini-2.5-pro-vision', messages)`, `completion('vertex_ai/gemini-2.5-pro-vision', messages)`| ## Gemini 1.5 Pro (and Vision) | Model Name | Function Call | @@ -1321,7 +1321,7 @@ curl --location 'https://0.0.0.0:4000/v1/chat/completions' \ #### Using Gemini Pro Vision -Call `gemini-pro-vision` in the same input/output format as OpenAI [`gpt-4-vision`](https://docs.litellm.ai/docs/providers/openai#openai-vision-models) +Call `gemini-2.5-pro-vision` in the same input/output format as OpenAI [`gpt-4-vision`](https://docs.litellm.ai/docs/providers/openai#openai-vision-models) LiteLLM Supports the following image types passed in `url` - Images with Cloud Storage URIs - gs://cloud-samples-data/generative-ai/image/boats.jpeg @@ -1339,7 +1339,7 @@ LiteLLM Supports the following image types passed in `url` import litellm response = litellm.completion( - model = "vertex_ai/gemini-pro-vision", + model = "vertex_ai/gemini-2.5-pro-vision", messages=[ { "role": "user", @@ -1377,7 +1377,7 @@ image_path = "cached_logo.jpg" # Getting the base64 string base64_image = encode_image(image_path) response = litellm.completion( - model="vertex_ai/gemini-pro-vision", + model="vertex_ai/gemini-2.5-pro-vision", messages=[ { "role": "user", @@ -1433,7 +1433,7 @@ tools = [ messages = [{"role": "user", "content": "What's the weather like in Boston today?"}] response = completion( - model="vertex_ai/gemini-pro-vision", + model="vertex_ai/gemini-2.5-pro-vision", messages=messages, tools=tools, ) From 33a11c4b074eed5bae488ae9fb7e7e5dbd2fe79d Mon Sep 17 00:00:00 2001 From: Otavio Brito Date: Mon, 22 Sep 2025 10:09:02 -0300 Subject: [PATCH 26/44] fix vllm passthrough --- litellm/llms/vllm/common_utils.py | 21 ++- .../proxy/common_utils/http_parsing_utils.py | 24 ++-- .../llm_passthrough_endpoints.py | 17 ++- .../test_llm_pass_through_endpoints.py | 134 ++++++++++++++++++ 4 files changed, 180 insertions(+), 16 deletions(-) diff --git a/litellm/llms/vllm/common_utils.py b/litellm/llms/vllm/common_utils.py index 8dca3e1de25..e2ed0daafe4 100644 --- a/litellm/llms/vllm/common_utils.py +++ b/litellm/llms/vllm/common_utils.py @@ -11,7 +11,21 @@ from litellm.utils import _add_path_to_api_base class VLLMError(BaseLLMException): - pass + def __init__( + self, + status_code: int, + message: str, + request: Optional[httpx.Request] = None, + response: Optional[httpx.Response] = None, + headers: Optional[Union[httpx.Headers, dict]] = None, + ): + super().__init__( + status_code=status_code, + message=message, + request=request, + response=response, + headers=headers, + ) class VLLMModelInfo(BaseLLMModelInfo): @@ -25,7 +39,8 @@ class VLLMModelInfo(BaseLLMModelInfo): api_key: Optional[str] = None, api_base: Optional[str] = None, ) -> dict: - """Google AI Studio sends api key in query params""" + if api_key is not None: + headers["x-api-key"] = api_key return headers @staticmethod @@ -53,7 +68,7 @@ class VLLMModelInfo(BaseLLMModelInfo): endpoint = "/v1/models" if api_base is None or api_key is None: raise ValueError( - "GEMINI_API_BASE or GEMINI_API_KEY is not set. Please set the environment variable, to query Gemini's `/models` endpoint." + "VLLM_API_BASE or VLLM_API_KEY is not set. Please set the environment variable, to query VLLM's `/models` endpoint." ) url = _add_path_to_api_base(api_base, endpoint) diff --git a/litellm/proxy/common_utils/http_parsing_utils.py b/litellm/proxy/common_utils/http_parsing_utils.py index 74da9992631..fd84eeda081 100644 --- a/litellm/proxy/common_utils/http_parsing_utils.py +++ b/litellm/proxy/common_utils/http_parsing_utils.py @@ -233,14 +233,16 @@ async def get_request_body(request: Request) -> Dict[str, Any]: """ Read the request body and parse it as JSON. """ - if request.headers.get("content-type") == "application/json": - return await _read_request_body(request) - elif ( - request.headers.get("content-type") == "multipart/form-data" - or request.headers.get("content-type") == "application/x-www-form-urlencoded" - ): - return await get_form_data(request) - else: - raise ValueError( - f"Unsupported content type: {request.headers.get('content-type')}" - ) + if request.method == "POST": + if request.headers.get("content-type", "") == "application/json": + return await _read_request_body(request) + elif ( + "multipart/form-data" in request.headers.get("content-type", "") + or "application/x-www-form-urlencoded" in request.headers.get("content-type", "") + ): + return await get_form_data(request) + else: + raise ValueError( + f"Unsupported content type: {request.headers.get('content-type')}" + ) + return {} \ No newline at end of file diff --git a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py index 56ee599325a..f4a963d4f46 100644 --- a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py @@ -108,7 +108,16 @@ async def llm_passthrough_factory_proxy_route( # Construct the full target URL using httpx base_url = httpx.URL(base_target_url) - updated_url = base_url.copy_with(path=encoded_endpoint) + # Join paths correctly by removing trailing/leading slashes as needed + if not base_url.path or base_url.path == "/": + # If base URL has no path, just use the new path + updated_url = base_url.copy_with(path=encoded_endpoint) + else: + # Otherwise, combine the paths + base_path = base_url.path.rstrip("/") + clean_path = encoded_endpoint.lstrip("/") + full_path = f"{base_path}/{clean_path}" + updated_url = base_url.copy_with(path=full_path) # Add or update query parameters provider_api_key = passthrough_endpoint_router.get_credentials( @@ -130,7 +139,11 @@ async def llm_passthrough_factory_proxy_route( is_streaming_request = False # anthropic is streaming when 'stream' = True is in the body if request.method == "POST": - _request_body = await request.json() + if "multipart/form-data" not in request.headers.get("content-type", ""): + _request_body = await request.json() + else: + _request_body = dict(request._form) + if _request_body.get("stream"): is_streaming_request = True 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 1702a317a29..6e718f67d35 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 @@ -19,6 +19,8 @@ from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import ( BaseOpenAIPassThroughHandler, RouteChecks, create_pass_through_route, + llm_passthrough_factory_proxy_route, + vllm_proxy_route, vertex_discovery_proxy_route, vertex_proxy_route, bedrock_llm_proxy_route, @@ -914,3 +916,135 @@ class TestBedrockLLMProxyRoute: # For regular models, model should be just the model ID assert call_kwargs["model"] == "anthropic.claude-3-sonnet-20240229-v1:0" assert result == "success" + + +class TestLLMPassthroughFactoryProxyRoute: + @pytest.mark.asyncio + async def test_llm_passthrough_factory_proxy_route_success(self): + mock_request = MagicMock(spec=Request) + mock_request.method = "POST" + mock_request.json = AsyncMock(return_value={"stream": False}) + mock_fastapi_response = MagicMock(spec=Response) + mock_user_api_key_dict = MagicMock() + + with patch( + "litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.ProviderConfigManager.get_provider_model_info" + ) as mock_get_provider, patch( + "litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.passthrough_endpoint_router.get_credentials" + ) as mock_get_creds, patch( + "litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.create_pass_through_route" + ) as mock_create_route: + mock_provider_config = MagicMock() + mock_provider_config.get_api_base.return_value = "https://example.com/v1" + mock_provider_config.validate_environment.return_value = { + "Authorization": "Bearer test-key" + } + mock_get_provider.return_value = mock_provider_config + mock_get_creds.return_value = "test-api-key" + + mock_endpoint_func = AsyncMock(return_value="success") + mock_create_route.return_value = mock_endpoint_func + + result = await llm_passthrough_factory_proxy_route( + custom_llm_provider="custom_provider", + endpoint="/chat/completions", + request=mock_request, + fastapi_response=mock_fastapi_response, + user_api_key_dict=mock_user_api_key_dict, + ) + + assert result == "success" + mock_get_provider.assert_called_once_with( + provider=litellm.LlmProviders("custom_provider"), model=None + ) + mock_get_creds.assert_called_once_with( + custom_llm_provider="custom_provider", region_name=None + ) + mock_create_route.assert_called_once_with( + endpoint="/chat/completions", + target="https://example.com/v1/chat/completions", + custom_headers={"Authorization": "Bearer test-key"}, + ) + mock_endpoint_func.assert_awaited_once() + + +class TestVLLMProxyRoute: + @pytest.mark.asyncio + async def test_vllm_proxy_route_with_router_model(self): + mock_request = MagicMock(spec=Request) + mock_request.method = "POST" + mock_request.headers = {"content-type": "application/json"} + mock_request.query_params = {} + + mock_fastapi_response = MagicMock(spec=Response) + mock_user_api_key_dict = MagicMock() + + with patch( + "litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.get_request_body", + return_value={"model": "router-model", "stream": False}, + ), patch( + "litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.is_passthrough_request_using_router_model", + return_value=True, + ) as mock_is_router, patch( + "litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.llm_router" + ) as mock_llm_router: + mock_llm_router.allm_passthrough_route = AsyncMock() + mock_response = httpx.Response(200, json={"response": "success"}) + mock_llm_router.allm_passthrough_route.return_value = mock_response + + await vllm_proxy_route( + endpoint="/chat/completions", + request=mock_request, + fastapi_response=mock_fastapi_response, + user_api_key_dict=mock_user_api_key_dict, + ) + + mock_is_router.assert_called_once() + mock_llm_router.allm_passthrough_route.assert_awaited_once_with( + model="router-model", + method="POST", + endpoint="/chat/completions", + request_query_params={}, + request_headers={"content-type": "application/json"}, + stream=False, + content=None, + data=None, + files=None, + json={"model": "router-model", "stream": False}, + params=None, + headers=None, + cookies=None, + ) + + @pytest.mark.asyncio + async def test_vllm_proxy_route_fallback_to_factory(self): + mock_request = MagicMock(spec=Request) + mock_fastapi_response = MagicMock(spec=Response) + mock_user_api_key_dict = MagicMock() + + with patch( + "litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.get_request_body", + return_value={"model": "other-model"}, + ), patch( + "litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.is_passthrough_request_using_router_model", + return_value=False, + ), patch( + "litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.llm_passthrough_factory_proxy_route" + ) as mock_factory_route: + mock_factory_route.return_value = "factory_success" + + result = await vllm_proxy_route( + endpoint="/chat/completions", + request=mock_request, + fastapi_response=mock_fastapi_response, + user_api_key_dict=mock_user_api_key_dict, + ) + + assert result == "factory_success" + mock_factory_route.assert_awaited_once_with( + endpoint="/chat/completions", + request=mock_request, + fastapi_response=mock_fastapi_response, + user_api_key_dict=mock_user_api_key_dict, + custom_llm_provider="vllm", + ) From 666641b2d4515f732c173749ac80936f5b77a289 Mon Sep 17 00:00:00 2001 From: Otavio Brito Date: Mon, 22 Sep 2025 10:25:05 -0300 Subject: [PATCH 27/44] fix get parsed form --- .../proxy/pass_through_endpoints/llm_passthrough_endpoints.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py index f4a963d4f46..5e171af5252 100644 --- a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py @@ -142,7 +142,7 @@ async def llm_passthrough_factory_proxy_route( if "multipart/form-data" not in request.headers.get("content-type", ""): _request_body = await request.json() else: - _request_body = dict(request._form) + _request_body = await get_form_data(request) if _request_body.get("stream"): is_streaming_request = True From ffd9117357c939705e056d6e524a06854255e017 Mon Sep 17 00:00:00 2001 From: Otavio Brito Date: Mon, 22 Sep 2025 11:06:11 -0300 Subject: [PATCH 28/44] fix failing test --- .../test_llm_pass_through_endpoints.py | 124 ++++++++---------- 1 file changed, 54 insertions(+), 70 deletions(-) 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 6e718f67d35..702ae4bd42f 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 @@ -921,6 +921,7 @@ class TestBedrockLLMProxyRoute: class TestLLMPassthroughFactoryProxyRoute: @pytest.mark.asyncio async def test_llm_passthrough_factory_proxy_route_success(self): + from litellm.types.utils import LlmProviders mock_request = MagicMock(spec=Request) mock_request.method = "POST" mock_request.json = AsyncMock(return_value={"stream": False}) @@ -928,7 +929,7 @@ class TestLLMPassthroughFactoryProxyRoute: mock_user_api_key_dict = MagicMock() with patch( - "litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.ProviderConfigManager.get_provider_model_info" + "litellm.utils.ProviderConfigManager.get_provider_model_info" ) as mock_get_provider, patch( "litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.passthrough_endpoint_router.get_credentials" ) as mock_get_creds, patch( @@ -937,16 +938,16 @@ class TestLLMPassthroughFactoryProxyRoute: mock_provider_config = MagicMock() mock_provider_config.get_api_base.return_value = "https://example.com/v1" mock_provider_config.validate_environment.return_value = { - "Authorization": "Bearer test-key" + "x-api-key": "dummy" } mock_get_provider.return_value = mock_provider_config - mock_get_creds.return_value = "test-api-key" + mock_get_creds.return_value = "dummy" mock_endpoint_func = AsyncMock(return_value="success") mock_create_route.return_value = mock_endpoint_func result = await llm_passthrough_factory_proxy_route( - custom_llm_provider="custom_provider", + custom_llm_provider=LlmProviders.VLLM, endpoint="/chat/completions", request=mock_request, fastapi_response=mock_fastapi_response, @@ -955,96 +956,79 @@ class TestLLMPassthroughFactoryProxyRoute: assert result == "success" mock_get_provider.assert_called_once_with( - provider=litellm.LlmProviders("custom_provider"), model=None + provider=litellm.LlmProviders(LlmProviders.VLLM), model=None ) mock_get_creds.assert_called_once_with( - custom_llm_provider="custom_provider", region_name=None + custom_llm_provider=LlmProviders.VLLM, region_name=None ) mock_create_route.assert_called_once_with( endpoint="/chat/completions", target="https://example.com/v1/chat/completions", - custom_headers={"Authorization": "Bearer test-key"}, + custom_headers={"x-api-key": "dummy"}, ) mock_endpoint_func.assert_awaited_once() class TestVLLMProxyRoute: @pytest.mark.asyncio - async def test_vllm_proxy_route_with_router_model(self): + @patch( + "litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.get_request_body", + return_value={"model": "router-model", "stream": False}, + ) + @patch( + "litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.is_passthrough_request_using_router_model", + return_value=True, + ) + @patch("litellm.proxy.proxy_server.llm_router") + async def test_vllm_proxy_route_with_router_model( + self, mock_llm_router, mock_is_router, mock_get_body + ): mock_request = MagicMock(spec=Request) mock_request.method = "POST" mock_request.headers = {"content-type": "application/json"} mock_request.query_params = {} - mock_fastapi_response = MagicMock(spec=Response) mock_user_api_key_dict = MagicMock() + mock_llm_router.allm_passthrough_route = AsyncMock( + return_value=httpx.Response(200, json={"response": "success"}) + ) - with patch( - "litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.get_request_body", - return_value={"model": "router-model", "stream": False}, - ), patch( - "litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.is_passthrough_request_using_router_model", - return_value=True, - ) as mock_is_router, patch( - "litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.llm_router" - ) as mock_llm_router: - mock_llm_router.allm_passthrough_route = AsyncMock() - mock_response = httpx.Response(200, json={"response": "success"}) - mock_llm_router.allm_passthrough_route.return_value = mock_response + await vllm_proxy_route( + endpoint="/chat/completions", + request=mock_request, + fastapi_response=mock_fastapi_response, + user_api_key_dict=mock_user_api_key_dict, + ) - await vllm_proxy_route( - endpoint="/chat/completions", - request=mock_request, - fastapi_response=mock_fastapi_response, - user_api_key_dict=mock_user_api_key_dict, - ) - - mock_is_router.assert_called_once() - mock_llm_router.allm_passthrough_route.assert_awaited_once_with( - model="router-model", - method="POST", - endpoint="/chat/completions", - request_query_params={}, - request_headers={"content-type": "application/json"}, - stream=False, - content=None, - data=None, - files=None, - json={"model": "router-model", "stream": False}, - params=None, - headers=None, - cookies=None, - ) + mock_is_router.assert_called_once() + mock_llm_router.allm_passthrough_route.assert_awaited_once() @pytest.mark.asyncio - async def test_vllm_proxy_route_fallback_to_factory(self): + @patch( + "litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.get_request_body", + return_value={"model": "other-model"}, + ) + @patch( + "litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.is_passthrough_request_using_router_model", + return_value=False, + ) + @patch( + "litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.llm_passthrough_factory_proxy_route" + ) + async def test_vllm_proxy_route_fallback_to_factory( + self, mock_factory_route, mock_is_router, mock_get_body + ): mock_request = MagicMock(spec=Request) mock_fastapi_response = MagicMock(spec=Response) mock_user_api_key_dict = MagicMock() + mock_factory_route.return_value = "factory_success" - with patch( - "litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.get_request_body", - return_value={"model": "other-model"}, - ), patch( - "litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.is_passthrough_request_using_router_model", - return_value=False, - ), patch( - "litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.llm_passthrough_factory_proxy_route" - ) as mock_factory_route: - mock_factory_route.return_value = "factory_success" + result = await vllm_proxy_route( + endpoint="/chat/completions", + request=mock_request, + fastapi_response=mock_fastapi_response, + user_api_key_dict=mock_user_api_key_dict, + ) - result = await vllm_proxy_route( - endpoint="/chat/completions", - request=mock_request, - fastapi_response=mock_fastapi_response, - user_api_key_dict=mock_user_api_key_dict, - ) - - assert result == "factory_success" - mock_factory_route.assert_awaited_once_with( - endpoint="/chat/completions", - request=mock_request, - fastapi_response=mock_fastapi_response, - user_api_key_dict=mock_user_api_key_dict, - custom_llm_provider="vllm", - ) + assert result == "factory_success" + mock_factory_route.assert_awaited_once() From fa20abfe48849ed6794252937de602f60db30ae3 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Mon, 22 Sep 2025 10:07:16 -0700 Subject: [PATCH 29/44] [Feat] Proxy CLI Auth - Allow re-using cli auth token (#14780) * fix: cli auth with SSO okta * fix: add LITTELM_CLI_SERVICE_ACCOUNT_NAME * fix: get_litellm_cli_user_api_key_auth * use existing_key CLI * fix: use existing key * test auth commands * test_cli_sso_callback_regenerate_vs_create_flow --- litellm/constants.py | 1 + litellm/proxy/_types.py | 16 ++ litellm/proxy/client/cli/commands/auth.py | 7 + litellm/proxy/management_endpoints/ui_sso.py | 153 +++++++++++++----- .../proxy/client/cli/test_auth_commands.py | 102 ++++++++++++ .../proxy/management_endpoints/test_ui_sso.py | 150 +++++++++++++++++ 6 files changed, 392 insertions(+), 37 deletions(-) diff --git a/litellm/constants.py b/litellm/constants.py index 9b44613b855..dd61ba3fa8b 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -947,6 +947,7 @@ HEALTH_CHECK_TIMEOUT_SECONDS = int( os.getenv("HEALTH_CHECK_TIMEOUT_SECONDS", 60) ) # 60 seconds LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME = "litellm-internal-health-check" +LITTELM_CLI_SERVICE_ACCOUNT_NAME = "litellm-cli" UI_SESSION_TOKEN_TEAM_ID = "litellm-dashboard" LITELLM_PROXY_ADMIN_NAME = "default_user_id" diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index b0a4e71e23a..9d5298c30f8 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -1915,6 +1915,22 @@ class UserAPIKeyAuth( key_alias=LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME, team_alias=LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME, ) + + @classmethod + def get_litellm_cli_user_api_key_auth(cls) -> "UserAPIKeyAuth": + """ + Returns a `UserAPIKeyAuth` object for the litellm internal health check service account. + + This is used to track number of requests/spend for health check calls. + """ + from litellm.constants import LITTELM_CLI_SERVICE_ACCOUNT_NAME + + return cls( + api_key=LITTELM_CLI_SERVICE_ACCOUNT_NAME, + team_id=LITTELM_CLI_SERVICE_ACCOUNT_NAME, + key_alias=LITTELM_CLI_SERVICE_ACCOUNT_NAME, + team_alias=LITTELM_CLI_SERVICE_ACCOUNT_NAME, + ) class UserInfoResponse(LiteLLMPydanticObjectBase): diff --git a/litellm/proxy/client/cli/commands/auth.py b/litellm/proxy/client/cli/commands/auth.py index 7d89d39ed70..ec57f649463 100644 --- a/litellm/proxy/client/cli/commands/auth.py +++ b/litellm/proxy/client/cli/commands/auth.py @@ -64,6 +64,9 @@ def login(ctx: click.Context): base_url = ctx.obj["base_url"] + # Check if we have an existing key to regenerate + existing_key = get_stored_api_key() + # Generate unique key ID for this login session key_id = f"sk-{str(uuid.uuid4())}" @@ -71,6 +74,10 @@ def login(ctx: click.Context): # Construct SSO login URL with CLI source and pre-generated key sso_url = f"{base_url}/sso/key/generate?source={LITELLM_CLI_SOURCE_IDENTIFIER}&key={key_id}" + # If we have an existing key, include it so the server can regenerate it + if existing_key: + sso_url += f"&existing_key={existing_key}" + click.echo(f"Opening browser to: {sso_url}") click.echo("Please complete the SSO authentication in your browser...") click.echo(f"Session ID: {key_id}") diff --git a/litellm/proxy/management_endpoints/ui_sso.py b/litellm/proxy/management_endpoints/ui_sso.py index e6fdcef2805..3c9fec3dee2 100644 --- a/litellm/proxy/management_endpoints/ui_sso.py +++ b/litellm/proxy/management_endpoints/ui_sso.py @@ -114,7 +114,7 @@ def process_sso_jwt_access_token( @router.get("/sso/key/generate", tags=["experimental"], include_in_schema=False) async def google_login( - request: Request, source: Optional[str] = None, key: Optional[str] = None + request: Request, source: Optional[str] = None, key: Optional[str] = None, existing_key: Optional[str] = None ): # noqa: PLR0915 """ Create Proxy API Keys using Google Workspace SSO. Requires setting PROXY_BASE_URL in .env @@ -173,12 +173,14 @@ async def google_login( redirect_url = SSOAuthenticationHandler.get_redirect_url_for_sso( request=request, sso_callback_route="sso/callback", + existing_key=existing_key, ) # Store CLI key in state for OAuth flow cli_state: Optional[str] = SSOAuthenticationHandler._get_cli_state( source=source, key=key, + existing_key=existing_key, ) # check if user defined a custom auth sso sign in handler, if yes, use it @@ -590,8 +592,12 @@ async def auth_callback(request: Request, state: Optional[str] = None): # noqa: if state and state.startswith(f"{LITELLM_CLI_SESSION_TOKEN_PREFIX}:"): # Extract the key ID from the state key_id = state.split(":", 1)[1] - verbose_proxy_logger.info(f"CLI SSO callback detected for key: {key_id}") - return await cli_sso_callback(request, key=key_id) + + # Get existing_key from query parameters if provided + existing_key = request.query_params.get("existing_key") + + verbose_proxy_logger.info(f"CLI SSO callback detected for key: {key_id}, existing_key: {existing_key}") + return await cli_sso_callback(request, key=key_id, existing_key=existing_key) from litellm.proxy._types import LiteLLM_JWTAuth from litellm.proxy.auth.handle_jwt import JWTHandler @@ -678,13 +684,59 @@ async def auth_callback(request: Request, state: Optional[str] = None): # noqa: ) -async def cli_sso_callback(request: Request, key: Optional[str] = None): - """CLI SSO callback - generates the key with pre-specified ID""" - verbose_proxy_logger.info(f"CLI SSO callback for key: {key}") +async def _regenerate_cli_key(existing_key: str, new_key: str) -> None: + """Regenerate an existing CLI key with a new token""" + from litellm.proxy._types import RegenerateKeyRequest, UserAPIKeyAuth + from litellm.proxy.management_endpoints.key_management_endpoints import ( + regenerate_key_fn, + ) + + verbose_proxy_logger.info(f"Regenerating existing CLI key: {existing_key}") + + admin_user_dict = UserAPIKeyAuth.get_litellm_cli_user_api_key_auth() + + regenerate_request = RegenerateKeyRequest( + key=existing_key, + new_key=new_key, + duration="24hr" + ) + + await regenerate_key_fn( + key=existing_key, + data=regenerate_request, + user_api_key_dict=admin_user_dict + ) + + verbose_proxy_logger.info(f"Regenerated CLI key: {new_key}") + +async def _create_new_cli_key(key: str) -> None: + """Create a new CLI key""" from litellm.proxy.management_endpoints.key_management_endpoints import ( generate_key_helper_fn, ) + + verbose_proxy_logger.info("Creating new CLI key") + + await generate_key_helper_fn( + request_type="key", + duration="24hr", + key_max_budget=litellm.max_ui_session_budget, + aliases={}, + config={}, + spend=0, + team_id="litellm-cli", + table_name="key", + token=key, + ) + + verbose_proxy_logger.info(f"Created new CLI key: {key}") + + +async def cli_sso_callback(request: Request, key: Optional[str] = None, existing_key: Optional[str] = None): + """CLI SSO callback - regenerates existing CLI key or creates new one""" + verbose_proxy_logger.info(f"CLI SSO callback for key: {key}, existing_key: {existing_key}") + from litellm.proxy.proxy_server import prisma_client if not key or not key.startswith("sk-"): @@ -698,21 +750,11 @@ async def cli_sso_callback(request: Request, key: Optional[str] = None): status_code=500, detail=CommonProxyErrors.db_not_connected_error.value ) - # Generate a simple key for CLI usage with the pre-specified key ID try: - await generate_key_helper_fn( - request_type="key", - duration="24hr", - key_max_budget=litellm.max_ui_session_budget, - aliases={}, - config={}, - spend=0, - team_id="litellm-cli", - table_name="key", - token=key, # Use the pre-specified key ID - ) - - verbose_proxy_logger.info(f"Generated CLI key: {key}") + if existing_key: + await _regenerate_cli_key(existing_key, key) + else: + await _create_new_cli_key(key) # Return success page from fastapi.responses import HTMLResponse @@ -725,8 +767,8 @@ async def cli_sso_callback(request: Request, key: Optional[str] = None): return HTMLResponse(content=html_content, status_code=200) except Exception as e: - verbose_proxy_logger.error(f"Error generating CLI key: {e}") - raise HTTPException(status_code=500, detail=f"Failed to generate key: {str(e)}") + verbose_proxy_logger.error(f"Error with CLI key: {e}") + raise HTTPException(status_code=500, detail=f"Failed to process CLI key: {str(e)}") @router.get("/sso/cli/poll/{key_id}", tags=["experimental"], include_in_schema=False) @@ -993,20 +1035,51 @@ class SSOAuthenticationHandler: # or a cryptographicly signed state that we can verify stateless # For simplification we are using a static state, this is not perfect but some # SSO providers do not allow stateless verification - redirect_params = {} - state = os.getenv("GENERIC_CLIENT_STATE", None) - - if state: - redirect_params["state"] = state - elif "okta" in generic_authorization_endpoint: - redirect_params["state"] = ( - uuid.uuid4().hex - ) # set state param for okta - required + redirect_params = SSOAuthenticationHandler._get_generic_sso_redirect_params( + state=state, + generic_authorization_endpoint=generic_authorization_endpoint + ) + return await generic_sso.get_login_redirect(**redirect_params) # type: ignore raise ValueError( "Unknown SSO provider. Please setup SSO with client IDs https://docs.litellm.ai/docs/proxy/admin_ui_sso" ) + @staticmethod + def _get_generic_sso_redirect_params( + state: Optional[str] = None, + generic_authorization_endpoint: Optional[str] = None + ) -> dict: + """ + Get redirect parameters for Generic SSO with proper state priority handling. + + Priority order: + 1. CLI state (if provided) + 2. GENERIC_CLIENT_STATE environment variable + 3. Generated UUID for Okta (if Okta endpoint detected) + + Args: + state: Optional state parameter (e.g., CLI state) + generic_authorization_endpoint: Authorization endpoint URL + + Returns: + dict: Redirect parameters for SSO login + """ + redirect_params = {} + + if state: + # CLI state takes priority + # the litellm proxy cli sends the "state" parameter to the proxy server for auth. We should maintain the state parameter for the cli if it is provided + redirect_params["state"] = state + else: + generic_client_state = os.getenv("GENERIC_CLIENT_STATE", None) + if generic_client_state: + redirect_params["state"] = generic_client_state + elif generic_authorization_endpoint and "okta" in generic_authorization_endpoint: + redirect_params["state"] = uuid.uuid4().hex # set state param for okta - required + + return redirect_params + @staticmethod def should_use_sso_handler( google_client_id: Optional[str] = None, @@ -1025,6 +1098,7 @@ class SSOAuthenticationHandler: def get_redirect_url_for_sso( request: Request, sso_callback_route: str, + existing_key: Optional[str] = None, ) -> str: """ Get the redirect URL for SSO @@ -1036,6 +1110,11 @@ class SSOAuthenticationHandler: redirect_url += sso_callback_route else: redirect_url += "/" + sso_callback_route + + # Append existing_key as query parameter if provided + if existing_key: + redirect_url += f"?existing_key={existing_key}" + return redirect_url @staticmethod @@ -1218,7 +1297,7 @@ class SSOAuthenticationHandler: return team_request @staticmethod - def _get_cli_state(source: Optional[str], key: Optional[str]) -> Optional[str]: + def _get_cli_state(source: Optional[str], key: Optional[str], existing_key: Optional[str] = None) -> Optional[str]: """ Checks the request 'source' if a cli state token was passed in @@ -1229,11 +1308,11 @@ class SSOAuthenticationHandler: LITELLM_CLI_SOURCE_IDENTIFIER, ) - return ( - f"{LITELLM_CLI_SESSION_TOKEN_PREFIX}:{key}" - if source == LITELLM_CLI_SOURCE_IDENTIFIER and key - else None - ) + if source == LITELLM_CLI_SOURCE_IDENTIFIER and key: + # Just use the key - existing_key will be passed separately via query params + return f"{LITELLM_CLI_SESSION_TOKEN_PREFIX}:{key}" + else: + return None @staticmethod async def get_redirect_response_from_openid( # noqa: PLR0915 diff --git a/tests/test_litellm/proxy/client/cli/test_auth_commands.py b/tests/test_litellm/proxy/client/cli/test_auth_commands.py index 611fde7767e..5ef96e4f9af 100644 --- a/tests/test_litellm/proxy/client/cli/test_auth_commands.py +++ b/tests/test_litellm/proxy/client/cli/test_auth_commands.py @@ -435,3 +435,105 @@ class TestWhoamiCommand: assert "✅ Authenticated" in result.output # Should calculate age based on timestamp=0 assert "Token age:" in result.output + + +class TestCLIKeyRegenerationFlow: + """Test the end-to-end CLI key regeneration flow from CLI perspective""" + + def setup_method(self): + """Setup for each test""" + self.runner = CliRunner() + + def test_login_with_existing_key_regeneration_flow(self): + """Test complete login flow when user has existing key - should regenerate it""" + mock_context = Mock() + mock_context.obj = {"base_url": "https://test.example.com"} + + # Mock existing stored key + existing_key = "sk-existing-key-123" + + # Mock successful regeneration response + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "status": "ready", + "key": "sk-regenerated-key-456" # New regenerated key + } + + with patch('webbrowser.open') as mock_browser, \ + patch('requests.get', return_value=mock_response) as mock_get, \ + patch('litellm.proxy.client.cli.commands.auth.get_stored_api_key', return_value=existing_key) as mock_get_stored, \ + patch('litellm.proxy.client.cli.commands.auth.save_token') as mock_save, \ + patch('litellm.proxy.client.cli.interface.show_commands') as mock_show_commands, \ + patch('uuid.uuid4', return_value='new-session-uuid-789'): + + result = self.runner.invoke(login, obj=mock_context.obj) + + assert result.exit_code == 0 + assert "✅ Login successful!" in result.output + assert "API Key: sk-regenerated-key-456" in result.output + + # Verify existing key was retrieved + mock_get_stored.assert_called_once() + + # Verify browser was opened with correct URL including existing key + mock_browser.assert_called_once() + call_args = mock_browser.call_args[0][0] + assert "https://test.example.com/sso/key/generate" in call_args + assert "source=litellm-cli" in call_args + assert "key=sk-new-session-uuid-789" in call_args + assert f"existing_key={existing_key}" in call_args + + # Verify polling was done with correct session key + mock_get.assert_called() + poll_url = mock_get.call_args[0][0] + assert "sk-new-session-uuid-789" in poll_url + + # Verify regenerated key was saved + mock_save.assert_called_once() + saved_data = mock_save.call_args[0][0] + assert saved_data['key'] == 'sk-regenerated-key-456' + assert saved_data['user_id'] == 'cli-user' + + mock_show_commands.assert_called_once() + + def test_login_without_existing_key_creation_flow(self): + """Test complete login flow when user has no existing key - should create new one""" + mock_context = Mock() + mock_context.obj = {"base_url": "https://test.example.com"} + + # Mock no existing key + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "status": "ready", + "key": "sk-new-created-key-789" + } + + with patch('webbrowser.open') as mock_browser, \ + patch('requests.get', return_value=mock_response), \ + patch('litellm.proxy.client.cli.commands.auth.get_stored_api_key', return_value=None) as mock_get_stored, \ + patch('litellm.proxy.client.cli.commands.auth.save_token') as mock_save, \ + patch('litellm.proxy.client.cli.interface.show_commands'), \ + patch('uuid.uuid4', return_value='new-session-uuid-999'): + + result = self.runner.invoke(login, obj=mock_context.obj) + + assert result.exit_code == 0 + assert "✅ Login successful!" in result.output + + # Verify existing key check was done + mock_get_stored.assert_called_once() + + # Verify browser was opened with correct URL WITHOUT existing key + mock_browser.assert_called_once() + call_args = mock_browser.call_args[0][0] + assert "https://test.example.com/sso/key/generate" in call_args + assert "source=litellm-cli" in call_args + assert "key=sk-new-session-uuid-999" in call_args + assert "existing_key=" not in call_args # Should not include existing_key param + + # Verify new key was saved + mock_save.assert_called_once() + saved_data = mock_save.call_args[0][0] + assert saved_data['key'] == 'sk-new-created-key-789' diff --git a/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py b/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py index d72e9f7caa5..80aebc98497 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py +++ b/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py @@ -1247,6 +1247,156 @@ class TestCustomUISSO: assert result.status_code == 303 +class TestCLIKeyRegenerationFlow: + """Test the end-to-end CLI key regeneration flow""" + + @pytest.mark.asyncio + async def test_cli_sso_callback_regenerate_existing_key(self): + """Test CLI SSO callback regenerating an existing key""" + from litellm.proxy.management_endpoints.ui_sso import cli_sso_callback + + # Mock request + mock_request = MagicMock(spec=Request) + + # Test data + existing_key = "sk-existing-key-123" + new_key = "sk-new-key-456" + + # Mock the regenerate helper function + with patch("litellm.proxy.management_endpoints.ui_sso._regenerate_cli_key") as mock_regenerate, \ + patch("litellm.proxy.proxy_server.prisma_client", MagicMock()), \ + patch("litellm.proxy.common_utils.html_forms.cli_sso_success.render_cli_sso_success_page", return_value="Success"): + + # Act + result = await cli_sso_callback( + request=mock_request, + key=new_key, + existing_key=existing_key + ) + + # Assert + mock_regenerate.assert_called_once_with(existing_key, new_key) + assert result.status_code == 200 + assert "Success" in result.body.decode() + + @pytest.mark.asyncio + async def test_cli_sso_callback_create_new_key(self): + """Test CLI SSO callback creating a new key when no existing key provided""" + from litellm.proxy.management_endpoints.ui_sso import cli_sso_callback + + # Mock request + mock_request = MagicMock(spec=Request) + + # Test data + new_key = "sk-new-key-789" + + # Mock the create helper function + with patch("litellm.proxy.management_endpoints.ui_sso._create_new_cli_key") as mock_create, \ + patch("litellm.proxy.proxy_server.prisma_client", MagicMock()), \ + patch("litellm.proxy.common_utils.html_forms.cli_sso_success.render_cli_sso_success_page", return_value="Success"): + + # Act + result = await cli_sso_callback( + request=mock_request, + key=new_key, + existing_key=None + ) + + # Assert + mock_create.assert_called_once_with(new_key) + assert result.status_code == 200 + assert "Success" in result.body.decode() + + @pytest.mark.asyncio + async def test_auth_callback_routes_to_cli_with_existing_key(self): + """Test that auth_callback properly routes CLI requests and preserves existing_key parameter""" + from litellm.constants import LITELLM_CLI_SESSION_TOKEN_PREFIX + from litellm.proxy.management_endpoints.ui_sso import auth_callback + + # Mock request with existing_key query parameter + mock_request = MagicMock(spec=Request) + mock_request.query_params.get.return_value = "sk-existing-cli-key-123" + + # CLI state + cli_state = f"{LITELLM_CLI_SESSION_TOKEN_PREFIX}:sk-new-session-key-456" + + # Mock the CLI callback + with patch("litellm.proxy.management_endpoints.ui_sso.cli_sso_callback") as mock_cli_callback: + mock_cli_callback.return_value = MagicMock() + + # Act + await auth_callback(request=mock_request, state=cli_state) + + # Assert + mock_cli_callback.assert_called_once_with( + mock_request, + key="sk-new-session-key-456", + existing_key="sk-existing-cli-key-123" + ) + + def test_get_redirect_url_preserves_existing_key(self): + """Test that redirect URL generation preserves existing_key parameter""" + from litellm.proxy.management_endpoints.ui_sso import SSOAuthenticationHandler + + # Mock request + mock_request = MagicMock() + mock_request.base_url = "https://test.litellm.ai/" + + with patch("litellm.proxy.utils.get_custom_url", return_value="https://test.litellm.ai"): + # Test with existing_key + redirect_url = SSOAuthenticationHandler.get_redirect_url_for_sso( + request=mock_request, + sso_callback_route="sso/callback", + existing_key="sk-existing-123" + ) + + assert "https://test.litellm.ai/sso/callback?existing_key=sk-existing-123" == redirect_url + + def test_get_redirect_url_without_existing_key(self): + """Test that redirect URL generation works without existing_key parameter""" + from litellm.proxy.management_endpoints.ui_sso import SSOAuthenticationHandler + + # Mock request + mock_request = MagicMock() + mock_request.base_url = "https://test.litellm.ai/" + + with patch("litellm.proxy.utils.get_custom_url", return_value="https://test.litellm.ai"): + # Test without existing_key + redirect_url = SSOAuthenticationHandler.get_redirect_url_for_sso( + request=mock_request, + sso_callback_route="sso/callback" + ) + + assert "https://test.litellm.ai/sso/callback" == redirect_url + + @pytest.mark.asyncio + async def test_cli_sso_callback_regenerate_vs_create_flow(self): + """Test CLI SSO callback calls regenerate_key_fn when existing_key provided, generate_key_helper_fn when not""" + from litellm.proxy.management_endpoints.ui_sso import cli_sso_callback + + mock_request = MagicMock(spec=Request) + + with patch("litellm.proxy.management_endpoints.key_management_endpoints.regenerate_key_fn") as mock_regenerate, \ + patch("litellm.proxy.management_endpoints.key_management_endpoints.generate_key_helper_fn") as mock_generate, \ + patch("litellm.proxy._types.UserAPIKeyAuth.get_litellm_cli_user_api_key_auth"), \ + patch("litellm.proxy.proxy_server.prisma_client", MagicMock()), \ + patch("litellm.proxy.common_utils.html_forms.cli_sso_success.render_cli_sso_success_page", return_value="Success"): + + # Test regeneration path + await cli_sso_callback(mock_request, key="sk-new-123", existing_key="sk-existing-456") + mock_regenerate.assert_called_once() + mock_generate.assert_not_called() + + # Reset mocks + mock_regenerate.reset_mock() + mock_generate.reset_mock() + + # Test creation path + await cli_sso_callback(mock_request, key="sk-new-789", existing_key=None) + mock_regenerate.assert_not_called() + mock_generate.assert_called_once() + + class TestProcessSSOJWTAccessToken: """Test the process_sso_jwt_access_token helper function""" From 0f1de92e194251cfb9e3e5b55b7f8f0f399a84c9 Mon Sep 17 00:00:00 2001 From: xprilion Date: Tue, 23 Sep 2025 00:23:36 +0530 Subject: [PATCH 30/44] remove redundant code block --- litellm/utils.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/litellm/utils.py b/litellm/utils.py index 2cffb5eb47e..515b6990eee 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -4000,17 +4000,6 @@ def get_optional_params( # noqa: PLR0915 else False ), ) - elif custom_llm_provider == "wandb": - optional_params = litellm.WandbConfig().map_openai_params( - non_default_params=non_default_params, - optional_params=optional_params, - model=model, - drop_params=( - drop_params - if drop_params is not None and isinstance(drop_params, bool) - else False - ), - ) elif custom_llm_provider == "azure": if litellm.AzureOpenAIO1Config().is_o_series_model(model=model): optional_params = litellm.AzureOpenAIO1Config().map_openai_params( From 4c66e1f6bf9fd54b8438af5016286eb55ac854be Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Mon, 22 Sep 2025 17:18:57 -0700 Subject: [PATCH 31/44] docs(provider_specific_params.md): fix docs --- .../docs/completion/provider_specific_params.md | 10 +++++----- docs/my-website/sidebars.js | 1 + 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/my-website/docs/completion/provider_specific_params.md b/docs/my-website/docs/completion/provider_specific_params.md index 772ca13e293..250b410c9c4 100644 --- a/docs/my-website/docs/completion/provider_specific_params.md +++ b/docs/my-website/docs/completion/provider_specific_params.md @@ -423,7 +423,7 @@ model_list: curl -X POST 'http://0.0.0.0:4000/chat/completions' \ -H 'Content-Type: application/json' \ -H 'Authorization: Bearer sk-1234' \ --D '{ +-d '{ "model": "llama-3-8b-instruct", "messages": [ { @@ -431,8 +431,9 @@ curl -X POST 'http://0.0.0.0:4000/chat/completions' \ "content": "What'\''s the weather like in Boston today?" } ], - "adapater_id": "my-special-adapter-id" # 👈 PROVIDER-SPECIFIC PARAM - }' + "adapater_id": "my-special-adapter-id" +}' +``` ## Provider-Specific Metadata Parameters @@ -482,5 +483,4 @@ response = litellm.completion( ``` - -``` \ No newline at end of file + \ No newline at end of file diff --git a/docs/my-website/sidebars.js b/docs/my-website/sidebars.js index fe6dbc27290..cfc29f30202 100644 --- a/docs/my-website/sidebars.js +++ b/docs/my-website/sidebars.js @@ -546,6 +546,7 @@ const sidebars = { items: [ "set_keys", "completion/token_usage", + "sdk/headers", "sdk_custom_pricing", "embedding/async_embedding", "embedding/moderation", From 70cd9d3d4e41e3d2ba329d0ff779e9d3c92709c3 Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Mon, 22 Sep 2025 17:38:11 -0700 Subject: [PATCH 32/44] test: fix unit test to work on github action --- .../hooks/test_dynamic_rate_limiter_v3.py | 352 +++++++++++------- 1 file changed, 211 insertions(+), 141 deletions(-) diff --git a/tests/test_litellm/proxy/hooks/test_dynamic_rate_limiter_v3.py b/tests/test_litellm/proxy/hooks/test_dynamic_rate_limiter_v3.py index a7b5e4f1b1d..05e0d4287a3 100644 --- a/tests/test_litellm/proxy/hooks/test_dynamic_rate_limiter_v3.py +++ b/tests/test_litellm/proxy/hooks/test_dynamic_rate_limiter_v3.py @@ -3,6 +3,7 @@ Test priority-based rate limiting for dynamic_rate_limiter_v3. Core tests to validate that priority weights are respected (0.9/0.1) instead of equal splitting (0.5/0.5). """ + import asyncio import os import sys @@ -25,22 +26,25 @@ from litellm.proxy.hooks.dynamic_rate_limiter_v3 import ( async def test_priority_weight_allocation(): """ Test that priority weights are correctly applied instead of equal splitting. - + With priority_reservation = {"high": 0.9, "low": 0.1}: - High priority should get 90% of TPM (900 out of 1000) - Low priority should get 10% of TPM (100 out of 1000) - + This validates the core fix where before it would split 50/50. """ + # Set up environment for premium feature + os.environ["LITELLM_LICENSE"] = "test-license-key" + # Set up priority reservations litellm.priority_reservation = {"high": 0.9, "low": 0.1} - + dual_cache = DualCache() handler = DynamicRateLimitHandler(internal_usage_cache=dual_cache) - + model = "test-model" total_tpm = 1000 - + llm_router = Router( model_list=[ { @@ -55,72 +59,75 @@ async def test_priority_weight_allocation(): ] ) handler.update_variables(llm_router=llm_router) - + # Test high priority allocation high_priority_user = UserAPIKeyAuth() high_priority_user.metadata = {"priority": "high"} - + high_descriptors = handler._create_priority_based_descriptors( model=model, user_api_key_dict=high_priority_user, priority="high", ) - + assert len(high_descriptors) == 1 high_descriptor = high_descriptors[0] expected_high_tpm = int(total_tpm * 0.9) # 900 actual_high_tpm = high_descriptor["rate_limit"]["tokens_per_unit"] - - assert actual_high_tpm == expected_high_tpm, ( - f"High priority should get {expected_high_tpm} TPM (90%), got {actual_high_tpm}" - ) + + assert ( + actual_high_tpm == expected_high_tpm + ), f"High priority should get {expected_high_tpm} TPM (90%), got {actual_high_tpm}" assert high_descriptor["value"] == f"{model}:high" - + # Test low priority allocation low_priority_user = UserAPIKeyAuth() low_priority_user.metadata = {"priority": "low"} - + low_descriptors = handler._create_priority_based_descriptors( model=model, user_api_key_dict=low_priority_user, priority="low", ) - + assert len(low_descriptors) == 1 low_descriptor = low_descriptors[0] expected_low_tpm = int(total_tpm * 0.1) # 100 actual_low_tpm = low_descriptor["rate_limit"]["tokens_per_unit"] - - assert actual_low_tpm == expected_low_tpm, ( - f"Low priority should get {expected_low_tpm} TPM (10%), got {actual_low_tpm}" - ) + + assert ( + actual_low_tpm == expected_low_tpm + ), f"Low priority should get {expected_low_tpm} TPM (10%), got {actual_low_tpm}" assert low_descriptor["value"] == f"{model}:low" - + # Verify the ratio is 9:1, not 1:1 (equal splitting) ratio = actual_high_tpm / actual_low_tpm expected_ratio = 9.0 - assert abs(ratio - expected_ratio) < 0.1, ( - f"High:Low ratio should be {expected_ratio}:1, got {ratio}:1" - ) + assert ( + abs(ratio - expected_ratio) < 0.1 + ), f"High:Low ratio should be {expected_ratio}:1, got {ratio}:1" @pytest.mark.asyncio async def test_concurrent_priority_requests(): """ - Test the core issue: 5 concurrent requests with different priorities should get + Test the core issue: 5 concurrent requests with different priorities should get proper allocation based on priority weights, not equal splitting. - + This tests the exact scenario mentioned: priorities 0.9 and 0.1 should be 0.9/0.1, not 0.5/0.5. """ + # Set up environment for premium feature + os.environ["LITELLM_LICENSE"] = "test-license-key" + # Set up the exact scenario from the issue litellm.priority_reservation = {"high": 0.9, "low": 0.1} - + dual_cache = DualCache() handler = DynamicRateLimitHandler(internal_usage_cache=dual_cache) - + model = "test-model" total_tpm = 1000 - + llm_router = Router( model_list=[ { @@ -135,23 +142,23 @@ async def test_concurrent_priority_requests(): ] ) handler.update_variables(llm_router=llm_router) - + # Create 5 concurrent users - 3 high priority, 2 low priority high_priority_users = [] low_priority_users = [] - + for i in range(3): # 3 high priority users user = UserAPIKeyAuth() user.metadata = {"priority": "high"} user.user_id = f"high_user_{i}" high_priority_users.append(user) - - for i in range(2): # 2 low priority users + + for i in range(2): # 2 low priority users user = UserAPIKeyAuth() user.metadata = {"priority": "low"} user.user_id = f"low_user_{i}" low_priority_users.append(user) - + # Test all high priority users get the same allocation (not divided) for user in high_priority_users: descriptors = handler._create_priority_based_descriptors( @@ -159,7 +166,7 @@ async def test_concurrent_priority_requests(): user_api_key_dict=user, priority="high", ) - + assert len(descriptors) == 1 descriptor = descriptors[0] # Each high priority user should get 900 TPM, not divided by 3 @@ -168,7 +175,7 @@ async def test_concurrent_priority_requests(): f"got {descriptor['rate_limit']['tokens_per_unit']}" ) assert descriptor["value"] == f"{model}:high" - + # Test all low priority users get the same allocation (not divided) for user in low_priority_users: descriptors = handler._create_priority_based_descriptors( @@ -176,7 +183,7 @@ async def test_concurrent_priority_requests(): user_api_key_dict=user, priority="low", ) - + assert len(descriptors) == 1 descriptor = descriptors[0] # Each low priority user should get 100 TPM, not divided by 2 @@ -191,21 +198,24 @@ async def test_concurrent_priority_requests(): async def test_100_concurrent_priority_requests(): """ Stress test: 100 concurrent requests with mixed priorities over 10 seconds. - + This validates that the priority system works correctly under high load: - 70 high priority requests (should get 900 TPM each) - 30 low priority requests (should get 100 TPM each) - Spread across 10 seconds to simulate real-world load """ + # Set up environment for premium feature + os.environ["LITELLM_LICENSE"] = "test-license-key" + # Set up priority reservations litellm.priority_reservation = {"high": 0.9, "low": 0.1} - + dual_cache = DualCache() handler = DynamicRateLimitHandler(internal_usage_cache=dual_cache) - + model = "stress-test-model" total_tpm = 1000 - + llm_router = Router( model_list=[ { @@ -221,108 +231,130 @@ async def test_100_concurrent_priority_requests(): ] ) handler.update_variables(llm_router=llm_router) - + # Create 100 users: 70 high priority, 30 low priority all_users = [] - + # 70 high priority users for i in range(70): user = UserAPIKeyAuth() user.metadata = {"priority": "high"} user.user_id = f"high_stress_user_{i}" all_users.append((user, "high", 900, 450)) # expected TPM, expected RPM - + # 30 low priority users for i in range(30): user = UserAPIKeyAuth() user.metadata = {"priority": "low"} user.user_id = f"low_stress_user_{i}" all_users.append((user, "low", 100, 50)) # expected TPM, expected RPM - + async def test_user_descriptors(user_data): """Test descriptor creation for a single user.""" user, priority, expected_tpm, expected_rpm = user_data - + descriptors = handler._create_priority_based_descriptors( model=model, user_api_key_dict=user, priority=priority, ) - - assert len(descriptors) == 1, f"User {user.user_id} should have exactly 1 descriptor" + + assert ( + len(descriptors) == 1 + ), f"User {user.user_id} should have exactly 1 descriptor" descriptor = descriptors[0] - + # Validate TPM allocation actual_tpm = descriptor["rate_limit"]["tokens_per_unit"] - assert actual_tpm == expected_tpm, ( - f"User {user.user_id} ({priority}) should get {expected_tpm} TPM, got {actual_tpm}" - ) - + assert ( + actual_tpm == expected_tpm + ), f"User {user.user_id} ({priority}) should get {expected_tpm} TPM, got {actual_tpm}" + # Validate RPM allocation actual_rpm = descriptor["rate_limit"]["requests_per_unit"] - assert actual_rpm == expected_rpm, ( - f"User {user.user_id} ({priority}) should get {expected_rpm} RPM, got {actual_rpm}" - ) - + assert ( + actual_rpm == expected_rpm + ), f"User {user.user_id} ({priority}) should get {expected_rpm} RPM, got {actual_rpm}" + # Validate descriptor key assert descriptor["value"] == f"{model}:{priority}" assert descriptor["key"] == "priority_model" - + return { "user_id": user.user_id, "priority": priority, "tpm": actual_tpm, "rpm": actual_rpm, - "success": True + "success": True, } - + # Run all 100 requests concurrently to simulate high load start_time = time.time() - + # Split into batches to simulate requests over 10 seconds batch_size = 10 # 10 requests per batch - batches = [all_users[i:i + batch_size] for i in range(0, len(all_users), batch_size)] - + batches = [ + all_users[i : i + batch_size] for i in range(0, len(all_users), batch_size) + ] + all_results = [] - + for batch_idx, batch in enumerate(batches): # Process each batch concurrently batch_tasks = [test_user_descriptors(user_data) for user_data in batch] batch_results = await asyncio.gather(*batch_tasks, return_exceptions=True) all_results.extend(batch_results) - + # Add small delay between batches to spread over ~10 seconds if batch_idx < len(batches) - 1: # Don't sleep after last batch await asyncio.sleep(1.0) # 1 second between batches - + end_time = time.time() total_duration = end_time - start_time - + # Validate that the test ran over approximately 10 seconds - assert total_duration >= 9.0, f"Test should take ~10 seconds, took {total_duration:.2f}s" + assert ( + total_duration >= 9.0 + ), f"Test should take ~10 seconds, took {total_duration:.2f}s" assert total_duration <= 15.0, f"Test took too long: {total_duration:.2f}s" - + # Validate all requests were successful - successful_results = [r for r in all_results if isinstance(r, dict) and r.get("success")] - assert len(successful_results) == 100, f"Expected 100 successful results, got {len(successful_results)}" - + successful_results = [ + r for r in all_results if isinstance(r, dict) and r.get("success") + ] + assert ( + len(successful_results) == 100 + ), f"Expected 100 successful results, got {len(successful_results)}" + # Validate priority distribution high_priority_results = [r for r in successful_results if r["priority"] == "high"] low_priority_results = [r for r in successful_results if r["priority"] == "low"] - - assert len(high_priority_results) == 70, f"Expected 70 high priority results, got {len(high_priority_results)}" - assert len(low_priority_results) == 30, f"Expected 30 low priority results, got {len(low_priority_results)}" - + + assert ( + len(high_priority_results) == 70 + ), f"Expected 70 high priority results, got {len(high_priority_results)}" + assert ( + len(low_priority_results) == 30 + ), f"Expected 30 low priority results, got {len(low_priority_results)}" + # Validate all high priority users got correct allocation for result in high_priority_results: - assert result["tpm"] == 900, f"High priority user {result['user_id']} got {result['tpm']} TPM, expected 900" - assert result["rpm"] == 450, f"High priority user {result['user_id']} got {result['rpm']} RPM, expected 450" - + assert ( + result["tpm"] == 900 + ), f"High priority user {result['user_id']} got {result['tpm']} TPM, expected 900" + assert ( + result["rpm"] == 450 + ), f"High priority user {result['user_id']} got {result['rpm']} RPM, expected 450" + # Validate all low priority users got correct allocation for result in low_priority_results: - assert result["tpm"] == 100, f"Low priority user {result['user_id']} got {result['tpm']} TPM, expected 100" - assert result["rpm"] == 50, f"Low priority user {result['user_id']} got {result['rpm']} RPM, expected 50" - + assert ( + result["tpm"] == 100 + ), f"Low priority user {result['user_id']} got {result['tpm']} TPM, expected 100" + assert ( + result["rpm"] == 50 + ), f"Low priority user {result['user_id']} got {result['rpm']} RPM, expected 50" + print(f"✅ Successfully processed 100 concurrent requests in {total_duration:.2f}s") print(f" - 70 high priority users: 900 TPM, 450 RPM each") print(f" - 30 low priority users: 100 TPM, 50 RPM each") @@ -333,17 +365,20 @@ async def test_100_concurrent_priority_requests(): async def test_concurrent_pre_call_hooks_stress(): """ Stress test: 50 concurrent pre-call hooks with priority enforcement. - + This tests the actual rate limiting logic under concurrent load. """ + # Set up environment for premium feature + os.environ["LITELLM_LICENSE"] = "test-license-key" + litellm.priority_reservation = {"premium": 0.8, "standard": 0.2} - + dual_cache = DualCache() handler = DynamicRateLimitHandler(internal_usage_cache=dual_cache) - + model = "pre-call-stress-model" total_tpm = 2000 - + llm_router = Router( model_list=[ { @@ -358,71 +393,80 @@ async def test_concurrent_pre_call_hooks_stress(): ] ) handler.update_variables(llm_router=llm_router) - + # Mock the v3 limiter to simulate different scenarios successful_requests = [] rate_limited_requests = [] - + async def mock_should_rate_limit(descriptors, parent_otel_span=None): """Mock rate limiter that allows premium users, limits some standard users.""" descriptor = descriptors[0] priority = descriptor["value"].split(":")[-1] - + if priority == "premium": # Allow all premium requests return { "overall_code": "OK", - "statuses": [{ - "code": "OK", - "descriptor_key": descriptor["value"], - "rate_limit_type": "tokens_per_unit", - "limit_remaining": 1000 - }] + "statuses": [ + { + "code": "OK", + "descriptor_key": descriptor["value"], + "rate_limit_type": "tokens_per_unit", + "limit_remaining": 1000, + } + ], } else: # Rate limit some standard requests (simulate load) import random + if random.random() < 0.3: # 30% of standard requests get rate limited return { "overall_code": "OVER_LIMIT", - "statuses": [{ - "code": "OVER_LIMIT", - "descriptor_key": descriptor["value"], - "rate_limit_type": "tokens_per_unit", - "limit_remaining": 0 - }] + "statuses": [ + { + "code": "OVER_LIMIT", + "descriptor_key": descriptor["value"], + "rate_limit_type": "tokens_per_unit", + "limit_remaining": 0, + } + ], } else: return { "overall_code": "OK", - "statuses": [{ - "code": "OK", - "descriptor_key": descriptor["value"], - "rate_limit_type": "tokens_per_unit", - "limit_remaining": 100 - }] + "statuses": [ + { + "code": "OK", + "descriptor_key": descriptor["value"], + "rate_limit_type": "tokens_per_unit", + "limit_remaining": 100, + } + ], } - + # Create 50 users: 30 premium, 20 standard users = [] - + for i in range(30): user = UserAPIKeyAuth() user.metadata = {"priority": "premium"} user.user_id = f"premium_hook_user_{i}" users.append((user, "premium")) - + for i in range(20): user = UserAPIKeyAuth() user.metadata = {"priority": "standard"} user.user_id = f"standard_hook_user_{i}" users.append((user, "standard")) - + async def make_request(user_data): """Make a pre-call hook request.""" user, priority = user_data - - with patch.object(handler.v3_limiter, 'should_rate_limit', side_effect=mock_should_rate_limit): + + with patch.object( + handler.v3_limiter, "should_rate_limit", side_effect=mock_should_rate_limit + ): try: result = await handler.async_pre_call_hook( user_api_key_dict=user, @@ -430,53 +474,79 @@ async def test_concurrent_pre_call_hooks_stress(): data={"model": model}, call_type="completion", ) - + # If no exception, request was allowed - successful_requests.append({ + successful_requests.append( + {"user_id": user.user_id, "priority": priority, "result": "allowed"} + ) + return { + "status": "success", "user_id": user.user_id, "priority": priority, - "result": "allowed" - }) - return {"status": "success", "user_id": user.user_id, "priority": priority} - + } + except Exception as e: # Request was rate limited - rate_limited_requests.append({ + rate_limited_requests.append( + {"user_id": user.user_id, "priority": priority, "error": str(e)} + ) + return { + "status": "rate_limited", "user_id": user.user_id, "priority": priority, - "error": str(e) - }) - return {"status": "rate_limited", "user_id": user.user_id, "priority": priority} - + } + # Run all 50 requests concurrently start_time = time.time() tasks = [make_request(user_data) for user_data in users] results = await asyncio.gather(*tasks, return_exceptions=True) end_time = time.time() - + # Analyze results - successful_count = len([r for r in results if isinstance(r, dict) and r["status"] == "success"]) - rate_limited_count = len([r for r in results if isinstance(r, dict) and r["status"] == "rate_limited"]) - + successful_count = len( + [r for r in results if isinstance(r, dict) and r["status"] == "success"] + ) + rate_limited_count = len( + [r for r in results if isinstance(r, dict) and r["status"] == "rate_limited"] + ) + # Validate that premium users were mostly successful (priority worked) - premium_results = [r for r in results if isinstance(r, dict) and r["priority"] == "premium"] + premium_results = [ + r for r in results if isinstance(r, dict) and r["priority"] == "premium" + ] premium_success = len([r for r in premium_results if r["status"] == "success"]) - - standard_results = [r for r in results if isinstance(r, dict) and r["priority"] == "standard"] + + standard_results = [ + r for r in results if isinstance(r, dict) and r["priority"] == "standard" + ] standard_success = len([r for r in standard_results if r["status"] == "success"]) - + # Premium users should have higher success rate due to priority - premium_success_rate = premium_success / len(premium_results) if premium_results else 0 - standard_success_rate = standard_success / len(standard_results) if standard_results else 0 - - assert premium_success_rate >= 0.9, f"Premium success rate should be >= 90%, got {premium_success_rate:.2%}" - assert standard_success_rate >= 0.5, f"Standard success rate should be >= 50%, got {standard_success_rate:.2%}" - assert premium_success_rate > standard_success_rate, "Premium should have higher success rate than standard" - + premium_success_rate = ( + premium_success / len(premium_results) if premium_results else 0 + ) + standard_success_rate = ( + standard_success / len(standard_results) if standard_results else 0 + ) + + assert ( + premium_success_rate >= 0.9 + ), f"Premium success rate should be >= 90%, got {premium_success_rate:.2%}" + assert ( + standard_success_rate >= 0.5 + ), f"Standard success rate should be >= 50%, got {standard_success_rate:.2%}" + assert ( + premium_success_rate > standard_success_rate + ), "Premium should have higher success rate than standard" + total_duration = end_time - start_time - + print(f"✅ Processed 50 concurrent pre-call hooks in {total_duration:.2f}s") - print(f" - Premium users: {premium_success}/{len(premium_results)} success ({premium_success_rate:.1%})") - print(f" - Standard users: {standard_success}/{len(standard_results)} success ({standard_success_rate:.1%})") + print( + f" - Premium users: {premium_success}/{len(premium_results)} success ({premium_success_rate:.1%})" + ) + print( + f" - Standard users: {standard_success}/{len(standard_results)} success ({standard_success_rate:.1%})" + ) print(f" - Total successful: {successful_count}/50 ({successful_count/50:.1%})") print(f" - Priority system working: Premium > Standard success rates") From 5bae1f65403930dbd6d12381b0c501cb30ddeb64 Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Mon, 22 Sep 2025 17:41:43 -0700 Subject: [PATCH 33/44] perf(test-mcp.yml): run mcp tests on linting --- .github/workflows/test-mcp.yml | 48 ++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 .github/workflows/test-mcp.yml diff --git a/.github/workflows/test-mcp.yml b/.github/workflows/test-mcp.yml new file mode 100644 index 00000000000..2da6980951a --- /dev/null +++ b/.github/workflows/test-mcp.yml @@ -0,0 +1,48 @@ +name: LiteLLM MCP Tests (folder - tests/mcp_tests) + +on: + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 25 + + steps: + - uses: actions/checkout@v4 + + - name: Thank You Message + run: | + echo "### 🙏 Thank you for contributing to LiteLLM!" >> $GITHUB_STEP_SUMMARY + echo "Your PR is being tested now. We appreciate your help in making LiteLLM better!" >> $GITHUB_STEP_SUMMARY + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.12' + + - name: Install Poetry + uses: snok/install-poetry@v1 + + - name: Install dependencies + run: | + poetry install --with dev,proxy-dev --extras "proxy semantic-router" + poetry run pip install "pytest==7.3.1" + poetry run pip install "pytest-retry==1.6.3" + poetry run pip install "pytest-cov==5.0.0" + poetry run pip install "pytest-asyncio==0.21.1" + poetry run pip install "respx==0.22.0" + poetry run pip install "pydantic==2.10.2" + poetry run pip install "mcp==1.10.1" + poetry run pip install pytest-xdist + + - name: Setup litellm-enterprise as local package + run: | + cd enterprise + python -m pip install -e . + cd .. + + - name: Run MCP tests + run: | + poetry run pytest tests/mcp_tests -x -vv -n 4 --cov=litellm --cov-report=xml --durations=5 From 471d646bb7dce35cabcbd48e0a1bc1de175b2e7a Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Mon, 22 Sep 2025 17:45:12 -0700 Subject: [PATCH 34/44] fix: fix type hint --- .../bedrock/chat/converse_transformation.py | 52 +++++++++++-------- 1 file changed, 31 insertions(+), 21 deletions(-) diff --git a/litellm/llms/bedrock/chat/converse_transformation.py b/litellm/llms/bedrock/chat/converse_transformation.py index e8094444330..00874da3a7f 100644 --- a/litellm/llms/bedrock/chat/converse_transformation.py +++ b/litellm/llms/bedrock/chat/converse_transformation.py @@ -200,8 +200,8 @@ class AmazonConverseConfig(BaseConfig): llm_provider="bedrock", ) - key_pattern = re.compile(r'^[a-zA-Z0-9\s:_@$#=/+,.-]{1,256}$') - value_pattern = re.compile(r'^[a-zA-Z0-9\s:_@$#=/+,.-]{0,256}$') + key_pattern = re.compile(r"^[a-zA-Z0-9\s:_@$#=/+,.-]{1,256}$") + value_pattern = re.compile(r"^[a-zA-Z0-9\s:_@$#=/+,.-]{0,256}$") for key, value in metadata.items(): if not isinstance(key, str): @@ -762,7 +762,9 @@ class AmazonConverseConfig(BaseConfig): return {} - def _prepare_request_params(self, optional_params: dict, model: str) -> tuple[dict, dict, dict]: + def _prepare_request_params( + self, optional_params: dict, model: str + ) -> Tuple[dict, dict, dict]: """Prepare and separate request parameters.""" inference_params = copy.deepcopy(optional_params) supported_converse_params = list( @@ -797,7 +799,13 @@ class AmazonConverseConfig(BaseConfig): return inference_params, additional_request_params, request_metadata - def _process_tools_and_beta(self, original_tools: list, model: str, headers: Optional[dict], additional_request_params: dict) -> tuple[List[ToolBlock], list]: + def _process_tools_and_beta( + self, + original_tools: list, + model: str, + headers: Optional[dict], + additional_request_params: dict, + ) -> tuple[List[ToolBlock], list]: """Process tools and collect anthropic_beta values.""" bedrock_tools: List[ToolBlock] = [] @@ -871,12 +879,16 @@ class AmazonConverseConfig(BaseConfig): ) # Prepare and separate parameters - inference_params, additional_request_params, request_metadata = self._prepare_request_params(optional_params, model) + inference_params, additional_request_params, request_metadata = ( + self._prepare_request_params(optional_params, model) + ) original_tools = inference_params.pop("tools", []) # Process tools and collect beta values - bedrock_tools, anthropic_beta_list = self._process_tools_and_beta(original_tools, model, headers, additional_request_params) + bedrock_tools, anthropic_beta_list = self._process_tools_and_beta( + original_tools, model, headers, additional_request_params + ) bedrock_tool_config: Optional[ToolConfigBlock] = None if len(bedrock_tools) > 0: @@ -1157,9 +1169,7 @@ class AmazonConverseConfig(BaseConfig): return message, returned_finish_reason - def _translate_message_content( - self, content_blocks: List[ContentBlock] - ) -> Tuple[ + def _translate_message_content(self, content_blocks: List[ContentBlock]) -> Tuple[ str, List[ChatCompletionToolCallChunk], Optional[List[BedrockConverseReasoningContentBlock]], @@ -1174,9 +1184,9 @@ class AmazonConverseConfig(BaseConfig): """ content_str = "" tools: List[ChatCompletionToolCallChunk] = [] - reasoningContentBlocks: Optional[ - List[BedrockConverseReasoningContentBlock] - ] = None + reasoningContentBlocks: Optional[List[BedrockConverseReasoningContentBlock]] = ( + None + ) for idx, content in enumerate(content_blocks): """ - Content is either a tool response or text @@ -1297,9 +1307,9 @@ class AmazonConverseConfig(BaseConfig): chat_completion_message: ChatCompletionResponseMessage = {"role": "assistant"} content_str = "" tools: List[ChatCompletionToolCallChunk] = [] - reasoningContentBlocks: Optional[ - List[BedrockConverseReasoningContentBlock] - ] = None + reasoningContentBlocks: Optional[List[BedrockConverseReasoningContentBlock]] = ( + None + ) if message is not None: ( @@ -1312,12 +1322,12 @@ class AmazonConverseConfig(BaseConfig): chat_completion_message["provider_specific_fields"] = { "reasoningContentBlocks": reasoningContentBlocks, } - chat_completion_message[ - "reasoning_content" - ] = self._transform_reasoning_content(reasoningContentBlocks) - chat_completion_message[ - "thinking_blocks" - ] = self._transform_thinking_blocks(reasoningContentBlocks) + chat_completion_message["reasoning_content"] = ( + self._transform_reasoning_content(reasoningContentBlocks) + ) + chat_completion_message["thinking_blocks"] = ( + self._transform_thinking_blocks(reasoningContentBlocks) + ) chat_completion_message["content"] = content_str if ( json_mode is True From 65532e59a32bde0c8088ea5128bc8428bfa8380c Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Mon, 22 Sep 2025 17:51:08 -0700 Subject: [PATCH 35/44] fix: fix type hint --- .../bedrock/chat/converse_transformation.py | 2 +- ...odel_prices_and_context_window_backup.json | 126 ++++++++++++++++++ 2 files changed, 127 insertions(+), 1 deletion(-) diff --git a/litellm/llms/bedrock/chat/converse_transformation.py b/litellm/llms/bedrock/chat/converse_transformation.py index 00874da3a7f..a61cfa39e47 100644 --- a/litellm/llms/bedrock/chat/converse_transformation.py +++ b/litellm/llms/bedrock/chat/converse_transformation.py @@ -805,7 +805,7 @@ class AmazonConverseConfig(BaseConfig): model: str, headers: Optional[dict], additional_request_params: dict, - ) -> tuple[List[ToolBlock], list]: + ) -> Tuple[List[ToolBlock], list]: """Process tools and collect anthropic_beta values.""" bedrock_tools: List[ToolBlock] = [] diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 43ea3af320f..aa30efd606e 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -20943,6 +20943,132 @@ "mode": "embedding", "output_cost_per_token": 0.0 }, + "wandb/openai/gpt-oss-120b": { + "max_tokens": 131072, + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "input_cost_per_token": 0.015, + "output_cost_per_token": 0.06, + "litellm_provider": "wandb", + "mode": "chat" + }, + "wandb/openai/gpt-oss-20b": { + "max_tokens": 131072, + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "input_cost_per_token": 0.005, + "output_cost_per_token": 0.02, + "litellm_provider": "wandb", + "mode": "chat" + }, + "wandb/zai-org/GLM-4.5": { + "max_tokens": 131072, + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "input_cost_per_token": 0.055, + "output_cost_per_token": 0.2, + "litellm_provider": "wandb", + "mode": "chat" + }, + "wandb/Qwen/Qwen3-235B-A22B-Instruct-2507": { + "max_tokens": 262144, + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "input_cost_per_token": 0.01, + "output_cost_per_token": 0.01, + "litellm_provider": "wandb", + "mode": "chat" + }, + "wandb/Qwen/Qwen3-Coder-480B-A35B-Instruct": { + "max_tokens": 262144, + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "input_cost_per_token": 0.1, + "output_cost_per_token": 0.15, + "litellm_provider": "wandb", + "mode": "chat" + }, + "wandb/Qwen/Qwen3-235B-A22B-Thinking-2507": { + "max_tokens": 262144, + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "input_cost_per_token": 0.01, + "output_cost_per_token": 0.01, + "litellm_provider": "wandb", + "mode": "chat" + }, + "wandb/moonshotai/Kimi-K2-Instruct": { + "max_tokens": 128000, + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "input_cost_per_token": 0.135, + "output_cost_per_token": 0.4, + "litellm_provider": "wandb", + "mode": "chat" + }, + "wandb/meta-llama/Llama-3.1-8B-Instruct": { + "max_tokens": 128000, + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "input_cost_per_token": 0.022, + "output_cost_per_token": 0.022, + "litellm_provider": "wandb", + "mode": "chat" + }, + "wandb/deepseek-ai/DeepSeek-V3.1": { + "max_tokens": 128000, + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "input_cost_per_token": 0.055, + "output_cost_per_token": 0.165, + "litellm_provider": "wandb", + "mode": "chat" + }, + "wandb/deepseek-ai/DeepSeek-R1-0528": { + "max_tokens": 161000, + "max_input_tokens": 161000, + "max_output_tokens": 161000, + "input_cost_per_token": 0.135, + "output_cost_per_token": 0.54, + "litellm_provider": "wandb", + "mode": "chat" + }, + "wandb/deepseek-ai/DeepSeek-V3-0324": { + "max_tokens": 161000, + "max_input_tokens": 161000, + "max_output_tokens": 161000, + "input_cost_per_token": 0.114, + "output_cost_per_token": 0.275, + "litellm_provider": "wandb", + "mode": "chat" + }, + "wandb/meta-llama/Llama-3.3-70B-Instruct": { + "max_tokens": 128000, + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "input_cost_per_token": 0.071, + "output_cost_per_token": 0.071, + "litellm_provider": "wandb", + "mode": "chat" + }, + "wandb/meta-llama/Llama-4-Scout-17B-16E-Instruct": { + "max_tokens": 64000, + "max_input_tokens": 64000, + "max_output_tokens": 64000, + "input_cost_per_token": 0.017, + "output_cost_per_token": 0.066, + "litellm_provider": "wandb", + "mode": "chat" + }, + "wandb/microsoft/Phi-4-mini-instruct": { + "max_tokens": 128000, + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "input_cost_per_token": 0.008, + "output_cost_per_token": 0.035, + "litellm_provider": "wandb", + "mode": "chat" + }, "watsonx/ibm/granite-3-8b-instruct": { "input_cost_per_token": 0.0002, "litellm_provider": "watsonx", From 00b36554b3112f77d03cb315a25d2880153c9ab2 Mon Sep 17 00:00:00 2001 From: eycjur Date: Tue, 23 Sep 2025 01:57:50 +0000 Subject: [PATCH 36/44] add unit test --- ...test_azure_ai_image_edit_transformation.py | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 tests/test_litellm/llms/azure_ai/image_edit/test_azure_ai_image_edit_transformation.py diff --git a/tests/test_litellm/llms/azure_ai/image_edit/test_azure_ai_image_edit_transformation.py b/tests/test_litellm/llms/azure_ai/image_edit/test_azure_ai_image_edit_transformation.py new file mode 100644 index 00000000000..249d19eceb3 --- /dev/null +++ b/tests/test_litellm/llms/azure_ai/image_edit/test_azure_ai_image_edit_transformation.py @@ -0,0 +1,32 @@ +import os +import sys + +sys.path.insert( + 0, os.path.abspath("../../../../..") +) # Adds the parent directory to the system path + +from litellm.llms.azure_ai.image_edit.transformation import AzureFoundryFluxImageEditConfig + + +def test_azure_ai_validate_environment(): + """Test Azure AI environment validation""" + config = AzureFoundryFluxImageEditConfig() + + headers = {} + config.validate_environment(headers, "FLUX.1-Kontext-pro", api_key="test-key") + assert "Api-Key" in headers + assert headers["Api-Key"] == "test-key" + + +def test_azure_ai_url_generation(): + """Test Azure AI URL generation""" + config = AzureFoundryFluxImageEditConfig() + + api_base = "https://test-endpoint.eastus2.inference.ai.azure.com" + complete_url = config.get_complete_url( + model="FLUX.1-Kontext-pro", + api_base=api_base, + litellm_params={"api_version": "2025-04-01-preview"} + ) + expected_url = f"{api_base}/openai/deployments/FLUX.1-Kontext-pro/images/edits?api-version=2025-04-01-preview" + assert complete_url == expected_url From f24d65b807f77a69a73cdaae40c7215b6d5bb5ab Mon Sep 17 00:00:00 2001 From: eycjur Date: Tue, 23 Sep 2025 02:51:19 +0000 Subject: [PATCH 37/44] add docs --- .../docs/providers/azure_ai_img_edit.md | 260 ++++++++++++++++++ docs/my-website/sidebars.js | 1 + 2 files changed, 261 insertions(+) create mode 100644 docs/my-website/docs/providers/azure_ai_img_edit.md diff --git a/docs/my-website/docs/providers/azure_ai_img_edit.md b/docs/my-website/docs/providers/azure_ai_img_edit.md new file mode 100644 index 00000000000..0d5408f0af4 --- /dev/null +++ b/docs/my-website/docs/providers/azure_ai_img_edit.md @@ -0,0 +1,260 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# Azure AI Image Editing + +Azure AI provides powerful image editing capabilities using FLUX models from Black Forest Labs to modify existing images based on text descriptions. + +## Overview + +| Property | Details | +|----------|---------| +| Description | Azure AI Image Editing uses FLUX models to modify existing images based on text prompts. | +| Provider Route on LiteLLM | `azure_ai/` | +| Provider Doc | [Azure AI FLUX Models ↗](https://techcommunity.microsoft.com/blog/azure-ai-foundry-blog/black-forest-labs-flux-1-kontext-pro-and-flux1-1-pro-now-available-in-azure-ai-f/4434659) | +| Supported Operations | [`/images/edits`](#image-editing) | + +## Setup + +### API Key & Base URL & API Version + +```python showLineNumbers +# Set your Azure AI API credentials +import os +os.environ["AZURE_AI_API_KEY"] = "your-api-key-here" +os.environ["AZURE_AI_API_BASE"] = "your-azure-ai-endpoint" # e.g., https://your-endpoint.eastus2.inference.ai.azure.com/ +os.environ["AZURE_AI_API_VERSION"] = "2025-04-01-preview" # Example API version +``` + +Get your API key and endpoint from [Azure AI Studio](https://ai.azure.com/). + +## Supported Models + +| Model Name | Description | Cost per Image | +|------------|-------------|----------------| +| `azure_ai/FLUX.1-Kontext-pro` | FLUX 1 Kontext Pro model with enhanced context understanding for editing | $0.04 | + +## Image Editing + +### Usage - LiteLLM Python SDK + + + + +```python showLineNumbers title="Basic Image Editing" +import os +import base64 +from pathlib import Path + +import litellm + +# Set your API credentials +os.environ["AZURE_AI_API_KEY"] = "your-api-key-here" +os.environ["AZURE_AI_API_BASE"] = "your-azure-ai-endpoint" +os.environ["AZURE_AI_API_VERSION"] = "2025-04-01-preview" + +# Edit an image with a prompt +response = litellm.image_edit( + model="azure_ai/FLUX.1-Kontext-pro", + image=open("path/to/your/image.png", "rb"), + prompt="Add a winter theme with snow and cold colors", + api_base=os.environ["AZURE_AI_API_BASE"], + api_key=os.environ["AZURE_AI_API_KEY"], + api_version=os.environ["AZURE_AI_API_VERSION"] +) + +img_base64 = response.data[0].get("b64_json") +img_bytes = base64.b64decode(img_base64) +path = Path("edited_image.png") +path.write_bytes(img_bytes) +``` + + + + + +```python showLineNumbers title="Async Image Editing" +import os +import base64 +from pathlib import Path + +import litellm +import asyncio + +# Set your API credentials +os.environ["AZURE_AI_API_KEY"] = "your-api-key-here" +os.environ["AZURE_AI_API_BASE"] = "your-azure-ai-endpoint" +os.environ["AZURE_AI_API_VERSION"] = "2025-04-01-preview" + +async def edit_image(): + # Edit image asynchronously + response = await litellm.aimage_edit( + model="azure_ai/FLUX.1-Kontext-pro", + image=open("path/to/your/image.png", "rb"), + prompt="Make this image look like a watercolor painting", + api_base=os.environ["AZURE_AI_API_BASE"], + api_key=os.environ["AZURE_AI_API_KEY"], + api_version=os.environ["AZURE_AI_API_VERSION"] + ) + img_base64 = response.data[0].get("b64_json") + img_bytes = base64.b64decode(img_base64) + path = Path("async_edited_image.png") + path.write_bytes(img_bytes) + +# Run the async function +asyncio.run(edit_image()) +``` + + + + + +```python showLineNumbers title="Advanced Image Editing with Parameters" +import os +import base64 +from pathlib import Path + +import litellm + +# Set your API credentials +os.environ["AZURE_AI_API_KEY"] = "your-api-key-here" +os.environ["AZURE_AI_API_BASE"] = "your-azure-ai-endpoint" +os.environ["AZURE_AI_API_VERSION"] = "2025-04-01-preview" + +# Edit image with additional parameters +response = litellm.image_edit( + model="azure_ai/FLUX.1-Kontext-pro", + image=open("path/to/your/image.png", "rb"), + prompt="Add magical elements like floating crystals and mystical lighting", + api_base=os.environ["AZURE_AI_API_BASE"], + api_key=os.environ["AZURE_AI_API_KEY"], + api_version=os.environ["AZURE_AI_API_VERSION"], + n=1 +) +img_base64 = response.data[0].get("b64_json") +img_bytes = base64.b64decode(img_base64) +path = Path("advanced_edited_image.png") +path.write_bytes(img_bytes) +``` + + + + +### Usage - LiteLLM Proxy Server + +#### 1. Configure your config.yaml + +```yaml showLineNumbers title="Azure AI Image Editing Configuration" +model_list: + - model_name: azure-flux-kontext-edit + litellm_params: + model: azure_ai/FLUX.1-Kontext-pro + api_key: os.environ/AZURE_AI_API_KEY + api_base: os.environ/AZURE_AI_API_BASE + api_version: os.environ/AZURE_AI_API_VERSION + model_info: + mode: image_edit + +general_settings: + master_key: sk-1234 +``` + +#### 2. Start LiteLLM Proxy Server + +```bash showLineNumbers title="Start LiteLLM Proxy Server" +litellm --config /path/to/config.yaml + +# RUNNING on http://0.0.0.0:4000 +``` + +#### 3. Make image editing requests with OpenAI Python SDK + + + + +```python showLineNumbers title="Azure AI Image Editing via Proxy - OpenAI SDK" +from openai import OpenAI + +# Initialize client with your proxy URL +client = OpenAI( + base_url="http://localhost:4000", # Your proxy URL + api_key="sk-1234" # Your proxy API key +) + +# Edit image with FLUX Kontext Pro +response = client.images.edit( + model="azure-flux-kontext-edit", + image=open("path/to/your/image.png", "rb"), + prompt="Transform this image into a beautiful oil painting style", +) + +img_base64 = response.data[0].b64_json +img_bytes = base64.b64decode(img_base64) +path = Path("proxy_edited_image.png") +path.write_bytes(img_bytes) +``` + + + + + +```python showLineNumbers title="Azure AI Image Editing via Proxy - LiteLLM SDK" +import litellm + +# Edit image through proxy +response = litellm.image_edit( + model="litellm_proxy/azure-flux-kontext-edit", + image=open("path/to/your/image.png", "rb"), + prompt="Add a mystical forest background with magical creatures", + api_base="http://localhost:4000", + api_key="sk-1234" +) + +img_base64 = response.data[0].b64_json +img_bytes = base64.b64decode(img_base64) +path = Path("proxy_edited_image.png") +path.write_bytes(img_bytes) +``` + + + + + +```bash showLineNumbers title="Azure AI Image Editing via Proxy - cURL" +curl --location 'http://localhost:4000/v1/images/edits' \ +--header 'Authorization: Bearer sk-1234' \ +--form 'model="azure-flux-kontext-edit"' \ +--form 'prompt="Convert this image to a vintage sepia tone with old-fashioned effects"' \ +--form 'image=@"path/to/your/image.png"' +``` + + + + +## Supported Parameters + +Azure AI Image Editing supports the following OpenAI-compatible parameters: + +| Parameter | Type | Description | Default | Example | +|-----------|------|-------------|---------|---------| +| `image` | file | The image file to edit | Required | File object or binary data | +| `prompt` | string | Text description of the desired changes | Required | `"Add snow and winter elements"` | +| `model` | string | The FLUX model to use for editing | Required | `"azure_ai/FLUX.1-Kontext-pro"` | +| `n` | integer | Number of edited images to generate (You can specify only 1) | `1` | `1` | +| `api_base` | string | Your Azure AI endpoint URL | Required | `"https://your-endpoint.eastus2.inference.ai.azure.com/"` | +| `api_key` | string | Your Azure AI API key | Required | Environment variable or direct value | +| `api_version` | string | API version for Azure AI | Required | `"2025-04-01-preview"` | + +## Getting Started + +1. Create an account at [Azure AI Studio](https://ai.azure.com/) +2. Deploy a FLUX model in your Azure AI Studio workspace +3. Get your API key and endpoint from the deployment details +4. Set your `AZURE_AI_API_KEY`, `AZURE_AI_API_BASE` and `AZURE_AI_API_VERSION` environment variables +5. Prepare your source image +6. Use `litellm.image_edit()` to modify your images with text instructions + +## Additional Resources + +- [Azure AI Studio Documentation](https://docs.microsoft.com/en-us/azure/ai-services/) +- [FLUX Models Announcement](https://techcommunity.microsoft.com/blog/azure-ai-foundry-blog/black-forest-labs-flux-1-kontext-pro-and-flux1-1-pro-now-available-in-azure-ai-f/4434659) \ No newline at end of file diff --git a/docs/my-website/sidebars.js b/docs/my-website/sidebars.js index fe6dbc27290..d7296e11ab0 100644 --- a/docs/my-website/sidebars.js +++ b/docs/my-website/sidebars.js @@ -383,6 +383,7 @@ const sidebars = { items: [ "providers/azure_ai", "providers/azure_ai_img", + "providers/azure_ai_img_edit", ] }, { From 3ff21bf30cde2cf8db3ea595b4b2b8865e53d9f2 Mon Sep 17 00:00:00 2001 From: eycjur Date: Tue, 23 Sep 2025 03:08:05 +0000 Subject: [PATCH 38/44] refactor --- litellm/llms/azure_ai/common_utils.py | 1 + litellm/llms/azure_ai/image_edit/transformation.py | 8 ++------ 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/litellm/llms/azure_ai/common_utils.py b/litellm/llms/azure_ai/common_utils.py index 64c424d23c6..704c7980744 100644 --- a/litellm/llms/azure_ai/common_utils.py +++ b/litellm/llms/azure_ai/common_utils.py @@ -29,6 +29,7 @@ class AzureFoundryModelInfo(BaseLLMModelInfo): api_version = ( api_version or litellm.api_version + or get_secret_str("AZURE_API_VERSION") or get_secret_str("AZURE_AI_API_VERSION") ) return api_version diff --git a/litellm/llms/azure_ai/image_edit/transformation.py b/litellm/llms/azure_ai/image_edit/transformation.py index e0be3f3d29a..47f612912ce 100644 --- a/litellm/llms/azure_ai/image_edit/transformation.py +++ b/litellm/llms/azure_ai/image_edit/transformation.py @@ -13,7 +13,7 @@ class AzureFoundryFluxImageEditConfig(OpenAIImageEditConfig): """ Azure AI Foundry FLUX image edit config - Supports FLUX models including FLUX-1.1-pro and FLUX-1-kontext-pro for image editing. + Supports FLUX models including FLUX-1-kontext-pro for image editing. Azure AI Foundry FLUX models handle image editing through the /images/edits endpoint, same as standard Azure OpenAI models. The request format uses multipart/form-data @@ -28,11 +28,7 @@ class AzureFoundryFluxImageEditConfig(OpenAIImageEditConfig): ) -> dict: """ Validate Azure AI Foundry environment and set up authentication - Uses Api-Key header format like Azure OpenAI - - Azure AI Foundry uses the same authentication format as Azure OpenAI: - - Header: Api-Key (not Authorization Bearer) - - Endpoint: /openai/deployments/{model}/images/edits (for image editing) + Uses Api-Key header format """ api_key = AzureFoundryModelInfo.get_api_key(api_key) From 9cb409903afcd48d1c3021ac87b2abc6545aa6c8 Mon Sep 17 00:00:00 2001 From: eycjur Date: Tue, 23 Sep 2025 03:08:59 +0000 Subject: [PATCH 39/44] restore --- litellm/llms/azure_ai/common_utils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/litellm/llms/azure_ai/common_utils.py b/litellm/llms/azure_ai/common_utils.py index 704c7980744..dcc9335e42d 100644 --- a/litellm/llms/azure_ai/common_utils.py +++ b/litellm/llms/azure_ai/common_utils.py @@ -30,7 +30,6 @@ class AzureFoundryModelInfo(BaseLLMModelInfo): api_version or litellm.api_version or get_secret_str("AZURE_API_VERSION") - or get_secret_str("AZURE_AI_API_VERSION") ) return api_version From 3e9540f58951aee86ea9d5f0920f9008544e87c1 Mon Sep 17 00:00:00 2001 From: Uzair Ali <72073401+uzaxirr@users.noreply.github.com> Date: Tue, 23 Sep 2025 09:45:35 +0530 Subject: [PATCH 40/44] feat: enable custom fields in mcp_info configuration (#14794) Allow proxy admins to add arbitrary metadata fields to MCP servers in config.yaml under mcp_servers..mcp_info, similar to how model_info already works. Changes: - Changed MCPInfo from TypedDict to Dict[str, Any] for flexibility - Updated load_servers_from_config to preserve all custom fields - Updated add_update_server to handle arbitrary fields from database - Added comprehensive unit tests covering all scenarios --- .../mcp_server/mcp_server_manager.py | 30 +-- .../types/mcp_server/mcp_server_manager.py | 9 +- .../mcp_server/test_mcp_custom_fields.py | 212 ++++++++++++++++++ 3 files changed, 231 insertions(+), 20 deletions(-) create mode 100644 tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_custom_fields.py diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index d0eadb36ba3..ab5d1b10bf3 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -121,15 +121,13 @@ class MCPServerManager: for server_name, server_config in mcp_servers_config.items(): validate_mcp_server_name(server_name) _mcp_info: Dict[str, Any] = server_config.get("mcp_info", None) or {} - # Convert Dict[str, Any] to MCPInfo properly - mcp_info: MCPInfo = { - "server_name": _mcp_info.get("server_name", server_name), - "description": _mcp_info.get( - "description", server_config.get("description", None) - ), - "logo_url": _mcp_info.get("logo_url", None), - "mcp_server_cost_info": _mcp_info.get("mcp_server_cost_info", None), - } + # Preserve all custom fields from config while setting defaults for core fields + mcp_info: MCPInfo = _mcp_info.copy() + # Set default values for core fields if not present + if "server_name" not in mcp_info: + mcp_info["server_name"] = server_name + if "description" not in mcp_info and server_config.get("description"): + mcp_info["description"] = server_config.get("description") # Use alias for name if present, else server_name alias = server_config.get("alias", None) @@ -243,6 +241,14 @@ class MCPServerManager: name_for_prefix = ( mcp_server.alias or mcp_server.server_name or mcp_server.server_id ) + # Preserve all custom fields from database while setting defaults for core fields + mcp_info: MCPInfo = _mcp_info.copy() + # Set default values for core fields if not present + if "server_name" not in mcp_info: + mcp_info["server_name"] = mcp_server.server_name or mcp_server.server_id + if "description" not in mcp_info and mcp_server.description: + mcp_info["description"] = mcp_server.description + new_server = MCPServer( server_id=mcp_server.server_id, name=name_for_prefix, @@ -251,11 +257,7 @@ class MCPServerManager: url=mcp_server.url, transport=cast(MCPTransportType, mcp_server.transport), auth_type=cast(MCPAuthType, mcp_server.auth_type), - mcp_info=MCPInfo( - server_name=mcp_server.server_name or mcp_server.server_id, - description=mcp_server.description, - mcp_server_cost_info=_mcp_info.get("mcp_server_cost_info", None), - ), + mcp_info=mcp_info, # Stdio-specific fields command=getattr(mcp_server, "command", None), args=getattr(mcp_server, "args", None) or [], diff --git a/litellm/types/mcp_server/mcp_server_manager.py b/litellm/types/mcp_server/mcp_server_manager.py index eb1eb3250ba..bd09ef9c199 100644 --- a/litellm/types/mcp_server/mcp_server_manager.py +++ b/litellm/types/mcp_server/mcp_server_manager.py @@ -1,4 +1,4 @@ -from typing import Dict, List, Optional +from typing import Any, Dict, List, Optional from pydantic import BaseModel, ConfigDict from typing_extensions import TypedDict @@ -7,11 +7,8 @@ from litellm.proxy._types import MCPAuthType, MCPTransportType from litellm.types.mcp import MCPServerCostInfo -class MCPInfo(TypedDict, total=False): - server_name: str - description: Optional[str] - logo_url: Optional[str] - mcp_server_cost_info: Optional[MCPServerCostInfo] +# MCPInfo now allows arbitrary additional fields for custom metadata +MCPInfo = Dict[str, Any] class MCPServer(BaseModel): diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_custom_fields.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_custom_fields.py new file mode 100644 index 00000000000..d2fa7853e78 --- /dev/null +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_custom_fields.py @@ -0,0 +1,212 @@ +""" +Test suite for MCP server custom fields functionality. + +Tests that mcp_info can accept arbitrary custom fields in addition to predefined ones. +""" +import pytest +import sys +import os +from unittest.mock import Mock, patch +from typing import Dict, Any + +# Add the path to find the modules +sys.path.insert( + 0, os.path.abspath("../../../..") +) # Adjust the path as needed + +from litellm.proxy._experimental.mcp_server.mcp_server_manager import MCPServerManager +from litellm.types.mcp import MCPAuth +from litellm.proxy._types import LiteLLM_MCPServerTable + + +class TestMCPCustomFields: + """Test custom fields functionality in MCP server configuration.""" + + def test_custom_fields_preserved_from_config(self): + """Test that custom fields in mcp_info are preserved when loading from config.""" + manager = MCPServerManager() + + # Mock config with custom fields + mock_config = { + "test_server": { + "url": "http://localhost:3000", + "transport": "http", + "auth_type": "bearer_token", + "authentication_token": "test-token", + "mcp_info": { + "server_name": "Test Server", + "description": "A test server", + "custom_field_1": "custom_value_1", + "custom_field_2": {"nested": "value"}, + "custom_field_3": ["list", "values"], + "priority": 10, + "tags": ["production", "api"] + } + } + } + + # Load servers from config + manager.load_servers_from_config(mock_config) + + # Get the loaded server + servers = list(manager.config_mcp_servers.values()) + assert len(servers) == 1 + + server = servers[0] + mcp_info = server.mcp_info + + # Verify standard fields are preserved + assert mcp_info["server_name"] == "Test Server" + assert mcp_info["description"] == "A test server" + + # Verify custom fields are preserved + assert mcp_info["custom_field_1"] == "custom_value_1" + assert mcp_info["custom_field_2"] == {"nested": "value"} + assert mcp_info["custom_field_3"] == ["list", "values"] + assert mcp_info["priority"] == 10 + assert mcp_info["tags"] == ["production", "api"] + + def test_custom_fields_preserved_from_database(self): + """Test that custom fields in mcp_info are preserved when adding from database.""" + manager = MCPServerManager() + + # Mock database record with custom fields + mock_server = Mock(spec=LiteLLM_MCPServerTable) + mock_server.server_id = "test-server-id" + mock_server.server_name = "Test Server" + mock_server.description = "A test server" + mock_server.url = "http://localhost:3000" + mock_server.transport = "http" + mock_server.auth_type = MCPAuth.bearer_token + mock_server.alias = None + mock_server.mcp_info = { + "server_name": "Test Server", + "description": "A test server", + "custom_db_field": "database_value", + "metadata": {"source": "database"}, + "version": "1.0.0" + } + mock_server.command = None + mock_server.args = None + mock_server.env = None + mock_server.mcp_access_groups = None + + # Add server to manager + manager.add_update_server(mock_server) + + # Get the added server + server = manager.get_mcp_server_by_id("test-server-id") + assert server is not None + + mcp_info = server.mcp_info + + # Verify standard fields are preserved + assert mcp_info["server_name"] == "Test Server" + assert mcp_info["description"] == "A test server" + + # Verify custom fields are preserved + assert mcp_info["custom_db_field"] == "database_value" + assert mcp_info["metadata"] == {"source": "database"} + assert mcp_info["version"] == "1.0.0" + + def test_empty_mcp_info_handled_gracefully(self): + """Test that empty or missing mcp_info is handled gracefully.""" + manager = MCPServerManager() + + # Config with empty mcp_info + mock_config = { + "test_server": { + "url": "http://localhost:3000", + "transport": "http", + "mcp_info": {} + } + } + + manager.load_servers_from_config(mock_config) + + servers = list(manager.config_mcp_servers.values()) + assert len(servers) == 1 + + server = servers[0] + mcp_info = server.mcp_info + + # Should have default server_name + assert mcp_info["server_name"] == "test_server" + + def test_missing_mcp_info_creates_defaults(self): + """Test that missing mcp_info creates appropriate defaults.""" + manager = MCPServerManager() + + # Config without mcp_info + mock_config = { + "test_server": { + "url": "http://localhost:3000", + "transport": "http", + "description": "Server description" + } + } + + manager.load_servers_from_config(mock_config) + + servers = list(manager.config_mcp_servers.values()) + assert len(servers) == 1 + + server = servers[0] + mcp_info = server.mcp_info + + # Should have default server_name and description from config + assert mcp_info["server_name"] == "test_server" + assert mcp_info["description"] == "Server description" + + def test_config_description_fallback(self): + """Test that description from config level is used as fallback.""" + manager = MCPServerManager() + + # Config with description at server level but not in mcp_info + mock_config = { + "test_server": { + "url": "http://localhost:3000", + "transport": "http", + "description": "Config level description", + "mcp_info": { + "custom_field": "custom_value" + } + } + } + + manager.load_servers_from_config(mock_config) + + servers = list(manager.config_mcp_servers.values()) + server = servers[0] + mcp_info = server.mcp_info + + # Should use config level description as fallback + assert mcp_info["description"] == "Config level description" + assert mcp_info["custom_field"] == "custom_value" + + def test_mcp_info_description_takes_precedence(self): + """Test that description in mcp_info takes precedence over config level.""" + manager = MCPServerManager() + + # Config with description at both levels + mock_config = { + "test_server": { + "url": "http://localhost:3000", + "transport": "http", + "description": "Config level description", + "mcp_info": { + "description": "MCP info description", + "custom_field": "custom_value" + } + } + } + + manager.load_servers_from_config(mock_config) + + servers = list(manager.config_mcp_servers.values()) + server = servers[0] + mcp_info = server.mcp_info + + # Should use mcp_info description, not config level + assert mcp_info["description"] == "MCP info description" + assert mcp_info["custom_field"] == "custom_value" \ No newline at end of file From b9ffa98c5583f34521cca1cb4f9e8cf739bfab4f Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Mon, 22 Sep 2025 21:28:38 -0700 Subject: [PATCH 41/44] [Feat] Proxy CLI: Create a python method to login using litellm proxy (#14782) * fix: cli auth with SSO okta * fix: add LITTELM_CLI_SERVICE_ACCOUNT_NAME * fix: get_litellm_cli_user_api_key_auth * use existing_key CLI * fix: use existing key * test auth commands * test_cli_sso_callback_regenerate_vs_create_flow * feat: add CLI Token Utilities * fix: get_stored_api_key * move file * fix: get_valid_models * fix config.yaml * TestCLITokenUtils * TestGetValidModelsWithCLI * fix: tie user id to keys created through CLI * fix: add teams interface to CLI * add /keys/update to the list client commands * fix /sso/cli/poll to return the user_id * fix: working TeamsManagementClient * fix CLI Login command * fixes for auth * Potential fix for code scanning alert no. 3400: Clear-text logging of sensitive information Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * ruff fix --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .../litellm_proxy_server/cli_token_usage.py | 62 ++++ .../integrations/prometheus.py | 1 - .../internal_user_endpoints.py | 1 - .../proxy/vector_stores/endpoints.py | 2 +- litellm/__init__.py | 3 + litellm/litellm_core_utils/cli_token_utils.py | 58 ++++ litellm/proxy/client/cli/commands/auth.py | 295 +++++++++++++++++- litellm/proxy/client/cli/commands/teams.py | 179 +++++++++++ litellm/proxy/client/cli/interface.py | 1 + litellm/proxy/client/cli/main.py | 3 + litellm/proxy/client/client.py | 10 +- litellm/proxy/client/keys.py | 64 +++- litellm/proxy/client/teams.py | 146 +++++++++ litellm/proxy/management_endpoints/ui_sso.py | 128 +++++--- litellm/proxy/proxy_config.yaml | 12 + litellm/types/proxy/ui_sso.py | 9 + litellm/utils.py | 29 +- .../test_cli_token_utils.py | 69 ++++ tests/test_litellm/test_utils.py | 50 +++ 19 files changed, 1055 insertions(+), 67 deletions(-) create mode 100644 cookbook/litellm_proxy_server/cli_token_usage.py create mode 100644 litellm/litellm_core_utils/cli_token_utils.py create mode 100644 litellm/proxy/client/cli/commands/teams.py create mode 100644 litellm/proxy/client/teams.py create mode 100644 tests/test_litellm/litellm_core_utils/test_cli_token_utils.py diff --git a/cookbook/litellm_proxy_server/cli_token_usage.py b/cookbook/litellm_proxy_server/cli_token_usage.py new file mode 100644 index 00000000000..6ee5555695e --- /dev/null +++ b/cookbook/litellm_proxy_server/cli_token_usage.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +""" +Example: Using CLI token with LiteLLM SDK + +This example shows how to use the CLI authentication token +in your Python scripts after running `litellm-proxy login`. +""" + +from textwrap import indent +import litellm +LITELLM_BASE_URL = "http://localhost:4000/" + + +def main(): + """Using CLI token with LiteLLM SDK""" + print("🚀 Using CLI Token with LiteLLM SDK") + print("=" * 40) + #litellm._turn_on_debug() + + # Get the CLI token + api_key = litellm.get_litellm_gateway_api_key() + + if not api_key: + print("❌ No CLI token found. Please run 'litellm-proxy login' first.") + return + + print("✅ Found CLI token.") + + available_models = litellm.get_valid_models( + check_provider_endpoint=True, + custom_llm_provider="litellm_proxy", + api_key=api_key, + api_base=LITELLM_BASE_URL + ) + + print("✅ Available models:") + if available_models: + for i, model in enumerate(available_models, 1): + print(f" {i:2d}. {model}") + else: + print(" No models available") + + # Use with LiteLLM + try: + response = litellm.completion( + model="litellm_proxy/gemini/gemini-2.5-flash", + messages=[{"role": "user", "content": "Hello from CLI token!"}], + api_key=api_key, + base_url=LITELLM_BASE_URL + ) + print(f"✅ LLM Response: {response.model_dump_json(indent=4)}") + except Exception as e: + print(f"❌ Error: {e}") + + +if __name__ == "__main__": + main() + + print("\n💡 Tips:") + print("1. Run 'litellm-proxy login' to authenticate first") + print("2. Replace 'https://your-proxy.com' with your actual proxy URL") + print("3. The token is stored locally at ~/.litellm/token.json") diff --git a/enterprise/litellm_enterprise/integrations/prometheus.py b/enterprise/litellm_enterprise/integrations/prometheus.py index 4a1e8d75435..4451d76bed0 100644 --- a/enterprise/litellm_enterprise/integrations/prometheus.py +++ b/enterprise/litellm_enterprise/integrations/prometheus.py @@ -2328,7 +2328,6 @@ def get_custom_labels_from_tags(tags: List[str]) -> Dict[str, str]: "tag_Service_web_app_v1": "false", } """ - import re from litellm.router_utils.pattern_match_deployments import PatternMatchRouter from litellm.types.integrations.prometheus import _sanitize_prometheus_label_name diff --git a/enterprise/litellm_enterprise/proxy/management_endpoints/internal_user_endpoints.py b/enterprise/litellm_enterprise/proxy/management_endpoints/internal_user_endpoints.py index e60b4d69905..2f53f9e9281 100644 --- a/enterprise/litellm_enterprise/proxy/management_endpoints/internal_user_endpoints.py +++ b/enterprise/litellm_enterprise/proxy/management_endpoints/internal_user_endpoints.py @@ -2,7 +2,6 @@ Enterprise internal user management endpoints """ -import os from fastapi import APIRouter, Depends, HTTPException diff --git a/enterprise/litellm_enterprise/proxy/vector_stores/endpoints.py b/enterprise/litellm_enterprise/proxy/vector_stores/endpoints.py index 43bdfa3844f..bb4b546b8d3 100644 --- a/enterprise/litellm_enterprise/proxy/vector_stores/endpoints.py +++ b/enterprise/litellm_enterprise/proxy/vector_stores/endpoints.py @@ -11,7 +11,7 @@ All /vector_store management endpoints import copy from typing import List, Optional -from fastapi import APIRouter, Depends, HTTPException, Request, Response +from fastapi import APIRouter, Depends, HTTPException import litellm from litellm._logging import verbose_proxy_logger diff --git a/litellm/__init__.py b/litellm/__init__.py index aa8357b6c78..7a68aa3a8d6 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -1343,5 +1343,8 @@ disable_hf_tokenizer_download: Optional[bool] = ( ) global_disable_no_log_param: bool = False +### CLI UTILITIES ### +from litellm.litellm_core_utils.cli_token_utils import get_litellm_gateway_api_key + ### PASSTHROUGH ### from .passthrough import allm_passthrough_route, llm_passthrough_route diff --git a/litellm/litellm_core_utils/cli_token_utils.py b/litellm/litellm_core_utils/cli_token_utils.py new file mode 100644 index 00000000000..2aedb1c19d2 --- /dev/null +++ b/litellm/litellm_core_utils/cli_token_utils.py @@ -0,0 +1,58 @@ +""" +CLI Token Utilities + +SDK-level utilities for reading CLI authentication tokens. +This module has no dependencies on proxy code and can be safely imported at the SDK level. +""" + +import json +import os +from pathlib import Path +from typing import Optional + + +def get_cli_token_file_path() -> str: + """Get the path to the CLI token file""" + home_dir = Path.home() + config_dir = home_dir / ".litellm" + return str(config_dir / "token.json") + + +def load_cli_token() -> Optional[dict]: + """Load CLI token data from file""" + token_file = get_cli_token_file_path() + if not os.path.exists(token_file): + return None + + try: + with open(token_file, 'r') as f: + return json.load(f) + except (json.JSONDecodeError, IOError): + return None + + +def get_litellm_gateway_api_key() -> Optional[str]: + """ + Get the stored CLI API key for use with LiteLLM SDK. + + This function reads the token file created by `litellm-proxy login` + and returns the API key for use in Python scripts. + + Returns: + str: The API key if found, None otherwise + + Example: + >>> import litellm + >>> api_key = litellm.get_litellm_gateway_api_key() + >>> if api_key: + >>> response = litellm.completion( + >>> model="gpt-3.5-turbo", + >>> messages=[{"role": "user", "content": "Hello"}], + >>> api_key=api_key, + >>> base_url="https://your-proxy.com/v1" + >>> ) + """ + token_data = load_cli_token() + if token_data and 'key' in token_data: + return token_data['key'] + return None diff --git a/litellm/proxy/client/cli/commands/auth.py b/litellm/proxy/client/cli/commands/auth.py index ec57f649463..9be74268059 100644 --- a/litellm/proxy/client/cli/commands/auth.py +++ b/litellm/proxy/client/cli/commands/auth.py @@ -1,11 +1,15 @@ import json import os +import sys import time import webbrowser from pathlib import Path -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional import click +import requests +from rich.console import Console +from rich.table import Table # Token storage utilities @@ -44,10 +48,256 @@ def clear_token() -> None: def get_stored_api_key() -> Optional[str]: """Get the stored API key from token file""" - token_data = load_token() - if token_data and 'key' in token_data: - return token_data['key'] - return None + # Use the SDK-level utility + from litellm.litellm_core_utils.cli_token_utils import get_litellm_gateway_api_key + return get_litellm_gateway_api_key() + +# Team selection utilities +def display_teams_table(teams: List[Dict[str, Any]]) -> None: + """Display teams in a formatted table""" + console = Console() + + if not teams: + console.print("❌ No teams found for your user.") + return + + table = Table(title="Available Teams") + table.add_column("Index", style="cyan", no_wrap=True) + table.add_column("Team Alias", style="magenta") + table.add_column("Team ID", style="green") + table.add_column("Models", style="yellow") + table.add_column("Max Budget", style="blue") + + for i, team in enumerate(teams): + team_alias = team.get("team_alias") or "N/A" + team_id = team.get("team_id", "N/A") + models = team.get("models", []) + max_budget = team.get("max_budget") + + # Format models list + if models: + if len(models) > 3: + models_str = ", ".join(models[:3]) + f" (+{len(models) - 3} more)" + else: + models_str = ", ".join(models) + else: + models_str = "All models" + + # Format budget + budget_str = f"${max_budget}" if max_budget else "Unlimited" + + table.add_row( + str(i + 1), + team_alias, + team_id, + models_str, + budget_str + ) + + console.print(table) + + +def get_user_teams(base_url: str, api_key: str, user_id: str) -> List[Dict[str, Any]]: + """Fetch teams for the current user""" + from litellm.proxy.client import Client + + client = Client(base_url=base_url, api_key=api_key) + try: + response = client.teams.list_v2(user_id=user_id) + # Extract just the teams array from the paginated response + if isinstance(response, dict) and 'teams' in response: + return response['teams'] + else: + # Fallback in case the response structure is different + return response if isinstance(response, list) else [] + except Exception as e: + click.echo(f"❌ Error fetching teams: {e}") + return [] + + +def get_key_input(): + """Get a single key input from the user (cross-platform)""" + try: + if sys.platform == 'win32': + import msvcrt + key = msvcrt.getch() + if key == b'\xe0': # Arrow keys on Windows + key = msvcrt.getch() + if key == b'H': # Up arrow + return 'up' + elif key == b'P': # Down arrow + return 'down' + elif key == b'\r': # Enter key + return 'enter' + elif key == b'\x1b': # Escape key + return 'escape' + elif key == b'q': + return 'quit' + return None + else: + import termios + import tty + fd = sys.stdin.fileno() + old_settings = termios.tcgetattr(fd) + try: + tty.setraw(sys.stdin.fileno()) + key = sys.stdin.read(1) + + if key == '\x1b': # Escape sequence + key += sys.stdin.read(2) + if key == '\x1b[A': # Up arrow + return 'up' + elif key == '\x1b[B': # Down arrow + return 'down' + elif key == '\x1b': # Just escape + return 'escape' + elif key == '\r' or key == '\n': # Enter key + return 'enter' + elif key == 'q': + return 'quit' + return None + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) + except ImportError: + # Fallback to simple input if termios/msvcrt not available + return None + + +def display_interactive_team_selection(teams: List[Dict[str, Any]], selected_index: int = 0) -> None: + """Display teams with one highlighted for selection""" + console = Console() + + # Clear the screen using Rich's method + console.clear() + + console.print("🎯 Select a Team (Use ↑↓ arrows, Enter to select, 'q' to skip):\n") + + for i, team in enumerate(teams): + team_alias = team.get("team_alias") or "N/A" + team_id = team.get("team_id", "N/A") + models = team.get("models", []) + max_budget = team.get("max_budget") + + # Format models list + if models: + if len(models) > 3: + models_str = ", ".join(models[:3]) + f" (+{len(models) - 3} more)" + else: + models_str = ", ".join(models) + else: + models_str = "All models" + + # Format budget + budget_str = f"${max_budget}" if max_budget else "Unlimited" + + # Highlight the selected item + if i == selected_index: + console.print(f"➤ [bold cyan]{team_alias}[/bold cyan] ({team_id})") + console.print(f" Models: [yellow]{models_str}[/yellow]") + console.print(f" Budget: [blue]{budget_str}[/blue]\n") + else: + console.print(f" [dim]{team_alias}[/dim] ({team_id})") + console.print(f" Models: [dim]{models_str}[/dim]") + console.print(f" Budget: [dim]{budget_str}[/dim]\n") + + +def prompt_team_selection(teams: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]: + """Interactive team selection with arrow keys""" + if not teams: + return None + + selected_index = 0 + + try: + # Check if we can use interactive mode + if not sys.stdin.isatty(): + # Fallback to simple selection for non-interactive environments + return prompt_team_selection_fallback(teams) + + while True: + display_interactive_team_selection(teams, selected_index) + + key = get_key_input() + + if key == 'up': + selected_index = (selected_index - 1) % len(teams) + elif key == 'down': + selected_index = (selected_index + 1) % len(teams) + elif key == 'enter': + selected_team = teams[selected_index] + # Clear screen and show selection + console = Console() + console.clear() + click.echo(f"✅ Selected team: {selected_team.get('team_alias', 'N/A')} ({selected_team.get('team_id')})") + return selected_team + elif key == 'quit' or key == 'escape': + # Clear screen + console = Console() + console.clear() + click.echo("ℹ️ Team selection skipped.") + return None + elif key is None: + # If we can't get key input, fall back to simple selection + return prompt_team_selection_fallback(teams) + + except KeyboardInterrupt: + console = Console() + console.clear() + click.echo("\n❌ Team selection cancelled.") + return None + except Exception: + # If interactive mode fails, fall back to simple selection + return prompt_team_selection_fallback(teams) + + +def prompt_team_selection_fallback(teams: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]: + """Fallback team selection for non-interactive environments""" + if not teams: + return None + + while True: + try: + choice = click.prompt( + "\nSelect a team by entering the index number (or 'skip' to continue without a team)", + type=str + ).strip() + + if choice.lower() == 'skip': + return None + + index = int(choice) - 1 + if 0 <= index < len(teams): + selected_team = teams[index] + click.echo(f"\n✅ Selected team: {selected_team.get('team_alias', 'N/A')} ({selected_team.get('team_id')})") + return selected_team + else: + click.echo(f"❌ Invalid selection. Please enter a number between 1 and {len(teams)}") + except ValueError: + click.echo("❌ Invalid input. Please enter a number or 'skip'") + except KeyboardInterrupt: + click.echo("\n❌ Team selection cancelled.") + return None + + +def update_key_with_team(base_url: str, api_key: str, team_id: str) -> bool: + """Update the API key to be associated with the selected team""" + + from litellm.proxy.client import Client + + client = Client(base_url=base_url, api_key=api_key) + try: + result = client.keys.update(key=api_key, team_id=team_id) + click.echo(f"✅ Successfully assigned key to team: {team_id}") + return True + except requests.exceptions.HTTPError as e: + # Bubble up the response text for detailed error info + error_msg = e.response.text if e.response else str(e) + click.echo(f"❌ Error updating key with team: {error_msg}") + return False + except Exception as e: + click.echo(f"❌ Error updating key with team: {e}") + return False + # Polling-based authentication - no local server needed @@ -57,8 +307,6 @@ def login(ctx: click.Context): """Login to LiteLLM proxy using SSO authentication""" import uuid - import requests - from litellm.constants import LITELLM_CLI_SOURCE_IDENTIFIER from litellm.proxy.client.cli.interface import show_commands @@ -116,6 +364,36 @@ def login(ctx: click.Context): click.echo(f"API Key: {api_key[:20]}...") click.echo("You can now use the CLI without specifying --api-key") + # Fetch and display user's teams + click.echo("\n" + "="*60) + click.echo("📋 Fetching your teams...") + + teams = get_user_teams( + base_url=base_url, + api_key=api_key, + user_id=data.get("user_id"), + ) + + + if teams: + # Prompt for team selection (will display teams interactively) + selected_team = prompt_team_selection(teams) + + if selected_team: + team_id = selected_team.get('team_id') + if team_id: + click.echo(f"\n🔄 Assigning your key to team: {selected_team.get('team_alias', team_id)}") + success = update_key_with_team(base_url, api_key, team_id) + if success: + click.echo(f"✅ Your CLI key is now associated with team: {selected_team.get('team_alias', team_id)}") + click.echo(f"🎯 You can now access models: {', '.join(selected_team.get('models', ['All models']))}") + else: + click.echo("⚠️ Key assignment failed, but you can still use the CLI") + else: + click.echo("ℹ️ Continuing without team assignment. You can assign a team later using the CLI.") + else: + click.echo("ℹ️ No teams found. You can create or join teams using the web interface.") + # Show available commands after successful login click.echo("\n" + "="*60) show_commands() @@ -171,5 +449,8 @@ def whoami(): if age_hours > 24: click.echo("⚠️ Warning: Token is more than 24 hours old and may have expired.") +# Export functions for use by other CLI commands +__all__ = ['login', 'logout', 'whoami', 'prompt_team_selection'] + # Export individual commands instead of grouping them # login, logout, and whoami will be added as top-level commands \ No newline at end of file diff --git a/litellm/proxy/client/cli/commands/teams.py b/litellm/proxy/client/cli/commands/teams.py new file mode 100644 index 00000000000..d4e05c890dc --- /dev/null +++ b/litellm/proxy/client/cli/commands/teams.py @@ -0,0 +1,179 @@ +"""Team management commands for LiteLLM CLI.""" + +from typing import Any, Dict, List, Optional + +import click +import requests +from rich.console import Console +from rich.table import Table + +from litellm.proxy.client import Client + + +@click.group() +def teams(): + """Manage teams and team assignments""" + pass + + +def display_teams_table(teams: List[Dict[str, Any]]) -> None: + """Display teams in a formatted table""" + console = Console() + + if not teams: + console.print("❌ No teams found for your user.") + return + + table = Table(title="Available Teams") + table.add_column("Index", style="cyan", no_wrap=True) + table.add_column("Team Alias", style="magenta") + table.add_column("Team ID", style="green") + table.add_column("Models", style="yellow") + table.add_column("Max Budget", style="blue") + table.add_column("Role", style="red") + + for i, team in enumerate(teams): + team_alias = team.get("team_alias") or "N/A" + team_id = team.get("team_id", "N/A") + models = team.get("models", []) + max_budget = team.get("max_budget") + + # Format models list + if models: + if len(models) > 3: + models_str = ", ".join(models[:3]) + f" (+{len(models) - 3} more)" + else: + models_str = ", ".join(models) + else: + models_str = "All models" + + # Format budget + budget_str = f"${max_budget}" if max_budget else "Unlimited" + + # Try to determine role (this might vary based on API response structure) + role = "Member" # Default role + if isinstance(team, dict) and 'members_with_roles' in team and team['members_with_roles']: + # This would need to be implemented based on actual API response structure + pass + + table.add_row( + str(i + 1), + team_alias, + team_id, + models_str, + budget_str, + role + ) + + console.print(table) + + +@teams.command() +@click.pass_context +def list(ctx: click.Context): + """List teams that you belong to""" + client = Client(ctx.obj["base_url"], ctx.obj["api_key"]) + + try: + # Use list() for simpler response structure (returns array directly) + teams = client.teams.list() + display_teams_table(teams) + except requests.exceptions.HTTPError as e: + click.echo(f"Error: HTTP {e.response.status_code}", err=True) + try: + error_body = e.response.json() + click.echo(f"Details: {error_body.get('detail', 'Unknown error')}", err=True) + except: + click.echo(e.response.text, err=True) + raise click.Abort() + except Exception as e: + click.echo(f"Error: {str(e)}", err=True) + raise click.Abort() + + +@teams.command() +@click.pass_context +def available(ctx: click.Context): + """List teams that are available to join""" + client = Client(ctx.obj["base_url"], ctx.obj["api_key"]) + + try: + teams = client.teams.get_available() + if teams: + console = Console() + console.print("\n🎯 Available Teams to Join:") + display_teams_table(teams) + else: + click.echo("ℹ️ No available teams to join.") + except requests.exceptions.HTTPError as e: + click.echo(f"Error: HTTP {e.response.status_code}", err=True) + try: + error_body = e.response.json() + click.echo(f"Details: {error_body.get('detail', 'Unknown error')}", err=True) + except: + click.echo(e.response.text, err=True) + raise click.Abort() + except Exception as e: + click.echo(f"Error: {str(e)}", err=True) + raise click.Abort() + + +@teams.command() +@click.option("--team-id", type=str, help="Team ID to assign the key to") +@click.pass_context +def assign_key(ctx: click.Context, team_id: Optional[str]): + """Assign your current CLI key to a team""" + client = Client(ctx.obj["base_url"], ctx.obj["api_key"]) + api_key = ctx.obj["api_key"] + + if not api_key: + click.echo("❌ No API key found. Please login first using 'litellm login'") + raise click.Abort() + + try: + # If no team_id provided, show teams and let user select + if not team_id: + teams = client.teams.list() + + if not teams: + click.echo("❌ No teams found for your user.") + return + + # Use interactive selection from auth module + from .auth import prompt_team_selection + selected_team = prompt_team_selection(teams) + + if selected_team: + team_id = selected_team.get('team_id') + else: + click.echo("❌ Operation cancelled.") + return + + # Update the key with the selected team + if team_id: + click.echo(f"\n🔄 Assigning your key to team: {team_id}") + result = client.keys.update(key=api_key, team_id=team_id) + click.echo(f"✅ Successfully assigned key to team: {team_id}") + + # Show team details if available + teams = client.teams.list() + for team in teams: + if team.get('team_id') == team_id: + models = team.get('models', []) + if models: + click.echo(f"🎯 You can now access models: {', '.join(models)}") + else: + click.echo("🎯 You can now access all available models") + break + + except requests.exceptions.HTTPError as e: + click.echo(f"Error: HTTP {e.response.status_code}", err=True) + try: + error_body = e.response.json() + click.echo(f"Details: {error_body.get('detail', 'Unknown error')}", err=True) + except: + click.echo(e.response.text, err=True) + raise click.Abort() + except Exception as e: + click.echo(f"Error: {str(e)}", err=True) + raise click.Abort() diff --git a/litellm/proxy/client/cli/interface.py b/litellm/proxy/client/cli/interface.py index d3f3a24eb45..78aeb442351 100644 --- a/litellm/proxy/client/cli/interface.py +++ b/litellm/proxy/client/cli/interface.py @@ -87,6 +87,7 @@ def show_commands(): ("chat", "Interactive chat with models"), ("http", "Make HTTP requests to the proxy"), ("keys", "Manage API keys"), + ("teams", "Manage teams and team assignments"), ("users", "Manage users"), ("version", "Show version information"), ("help", "Show this help message"), diff --git a/litellm/proxy/client/cli/main.py b/litellm/proxy/client/cli/main.py index fb4a37c3a17..eab9b31482a 100644 --- a/litellm/proxy/client/cli/main.py +++ b/litellm/proxy/client/cli/main.py @@ -15,6 +15,7 @@ from .commands.keys import keys # local imports from .commands.models import models +from .commands.teams import teams from .commands.users import users from .interface import interactive_shell @@ -98,6 +99,8 @@ cli.add_command(chat) cli.add_command(http) # Add the keys command group cli.add_command(keys) +# Add the teams command group +cli.add_command(teams) # Add the users command group cli.add_command(users) diff --git a/litellm/proxy/client/client.py b/litellm/proxy/client/client.py index 93e877d1563..8ed9a4ff89f 100644 --- a/litellm/proxy/client/client.py +++ b/litellm/proxy/client/client.py @@ -1,11 +1,12 @@ from typing import Optional -from .http_client import HTTPClient -from .models import ModelsManagementClient -from .model_groups import ModelGroupsManagementClient from .chat import ChatClient -from .keys import KeysManagementClient from .credentials import CredentialsManagementClient +from .http_client import HTTPClient +from .keys import KeysManagementClient +from .model_groups import ModelGroupsManagementClient +from .models import ModelsManagementClient +from .teams import TeamsManagementClient class Client: @@ -36,3 +37,4 @@ class Client: self.chat = ChatClient(base_url=self._base_url, api_key=self._api_key) self.keys = KeysManagementClient(base_url=self._base_url, api_key=self._api_key) self.credentials = CredentialsManagementClient(base_url=self._base_url, api_key=self._api_key) + self.teams = TeamsManagementClient(base_url=self._base_url, api_key=self._api_key) diff --git a/litellm/proxy/client/keys.py b/litellm/proxy/client/keys.py index 8c62eb8e4d0..fc307648a2f 100644 --- a/litellm/proxy/client/keys.py +++ b/litellm/proxy/client/keys.py @@ -1,5 +1,7 @@ +from typing import Any, Dict, List, Optional, Union + import requests -from typing import Dict, Any, Optional, Union, List + from .exceptions import UnauthorizedError @@ -221,6 +223,66 @@ class KeysManagementClient: raise UnauthorizedError(e) raise + def update( + self, + key: str, + models: Optional[List[str]] = None, + aliases: Optional[Dict[str, str]] = None, + spend: Optional[float] = None, + duration: Optional[str] = None, + key_alias: Optional[str] = None, + team_id: Optional[str] = None, + user_id: Optional[str] = None, + ) -> Union[Dict[str, Any], requests.Request]: + """ + Update an existing API key's parameters. + + Args: + models: Optional[List[str]] = None, + aliases: Optional[Dict[str, str]] = None, + spend: Optional[float] = None, + duration: Optional[str] = None, + key_alias: Optional[str] = None, + team_id: Optional[str] = None, + user_id: Optional[str] = None, + + Returns: + Union[Dict[str, Any], requests.Request]: Either the response from the server or + a prepared request object if return_request is True + + Raises: + UnauthorizedError: If the request fails with a 401 status code + requests.exceptions.RequestException: If the request fails with any other error + """ + url = f"{self._base_url}/key/update" + + data: Dict[str, Any] = {"key": key} + + if key_alias is not None: + data["key_alias"] = key_alias + if user_id is not None: + data["user_id"] = user_id + if team_id is not None: + data["team_id"] = team_id + if models is not None: + data["models"] = models + if spend is not None: + data["spend"] = spend + if duration is not None: + data["duration"] = duration + if aliases is not None: + data["aliases"] = aliases + request = requests.Request("POST", url, headers=self._get_headers(), json=data) + session = requests.Session() + try: + response = session.send(request.prepare()) + response.raise_for_status() + return response.json() + except requests.exceptions.HTTPError as e: + if e.response.status_code == 401: + raise UnauthorizedError(e) + raise + def info(self, key: str, return_request: bool = False) -> Union[Dict[str, Any], requests.Request]: """ Get information about API keys. diff --git a/litellm/proxy/client/teams.py b/litellm/proxy/client/teams.py new file mode 100644 index 00000000000..61ddbe6adae --- /dev/null +++ b/litellm/proxy/client/teams.py @@ -0,0 +1,146 @@ +"""Teams management client for LiteLLM proxy.""" + +from typing import Any, Dict, List, Optional + +import requests + +from .exceptions import UnauthorizedError + + +class TeamsManagementClient: + """Client for managing teams in LiteLLM proxy.""" + + def __init__(self, base_url: str, api_key: Optional[str] = None): + """ + Initialize the TeamsManagementClient. + + Args: + base_url (str): The base URL of the LiteLLM proxy server (e.g., "http://localhost:4000") + api_key (Optional[str]): API key for authentication. If provided, it will be sent as a Bearer token. + """ + self._base_url = base_url.rstrip("/") # Remove trailing slash if present + self._api_key = api_key + + def _get_headers(self) -> Dict[str, str]: + """ + Get the headers for API requests, including authorization if api_key is set. + + Returns: + Dict[str, str]: Headers to use for API requests + """ + headers = {"Content-Type": "application/json"} + if self._api_key: + headers["Authorization"] = f"Bearer {self._api_key}" + return headers + + def list( + self, + user_id: Optional[str] = None, + organization_id: Optional[str] = None, + ) -> List[Dict[str, Any]]: + """ + List teams that the user belongs to. + + Args: + user_id (Optional[str]): Only return teams which this user belongs to + organization_id (Optional[str]): Only return teams which belong to this organization + + Returns: + List[Dict[str, Any]]: List of team objects + + Raises: + requests.exceptions.HTTPError: If the request fails + UnauthorizedError: If authentication fails + """ + url = f"{self._base_url}/team/list" + params = {} + if user_id: + params["user_id"] = user_id + if organization_id: + params["organization_id"] = organization_id + + response = requests.get(url, headers=self._get_headers(), params=params) + + if response.status_code == 401: + raise UnauthorizedError("Authentication failed. Check your API key.") + + response.raise_for_status() + return response.json() + + def list_v2( + self, + user_id: Optional[str] = None, + organization_id: Optional[str] = None, + team_id: Optional[str] = None, + team_alias: Optional[str] = None, + page: int = 1, + page_size: int = 10, + sort_by: Optional[str] = None, + sort_order: str = "asc", + ) -> Dict[str, Any]: + """ + Get a paginated list of teams with filtering and sorting options. + + Args: + user_id (Optional[str]): Only return teams which this user belongs to + organization_id (Optional[str]): Only return teams which belong to this organization + team_id (Optional[str]): Filter teams by exact team_id match + team_alias (Optional[str]): Filter teams by partial team_alias match + page (int): Page number for pagination + page_size (int): Number of teams per page + sort_by (Optional[str]): Column to sort by (e.g. 'team_id', 'team_alias', 'created_at') + sort_order (str): Sort order ('asc' or 'desc') + + Returns: + Dict[str, Any]: Paginated response containing teams and pagination info + + Raises: + requests.exceptions.HTTPError: If the request fails + UnauthorizedError: If authentication fails + """ + url = f"{self._base_url}/v2/team/list" + params = { + "page": page, + "page_size": page_size, + "sort_order": sort_order, + } + + if user_id: + params["user_id"] = user_id + if organization_id: + params["organization_id"] = organization_id + if team_id: + params["team_id"] = team_id + if team_alias: + params["team_alias"] = team_alias + if sort_by: + params["sort_by"] = sort_by + + response = requests.get(url, headers=self._get_headers(), params=params) + + if response.status_code == 401: + raise UnauthorizedError("Authentication failed. Check your API key.") + + response.raise_for_status() + return response.json() + + def get_available(self) -> List[Dict[str, Any]]: + """ + Get list of available teams that the user can join. + + Returns: + List[Dict[str, Any]]: List of available team objects + + Raises: + requests.exceptions.HTTPError: If the request fails + UnauthorizedError: If authentication fails + """ + url = f"{self._base_url}/team/available" + + response = requests.get(url, headers=self._get_headers()) + + if response.status_code == 401: + raise UnauthorizedError("Authentication failed. Check your API key.") + + response.raise_for_status() + return response.json() diff --git a/litellm/proxy/management_endpoints/ui_sso.py b/litellm/proxy/management_endpoints/ui_sso.py index 3c9fec3dee2..3828f7318c1 100644 --- a/litellm/proxy/management_endpoints/ui_sso.py +++ b/litellm/proxy/management_endpoints/ui_sso.py @@ -67,6 +67,7 @@ from litellm.proxy.utils import ( ) from litellm.secret_managers.main import get_secret_bool, str_to_bool from litellm.types.proxy.management_endpoints.ui_sso import * +from litellm.types.proxy.ui_sso import ParsedOpenIDResult if TYPE_CHECKING: from fastapi_sso.sso.base import OpenID @@ -588,17 +589,6 @@ async def auth_callback(request: Request, state: Optional[str] = None): # noqa: # Check if this is a CLI login (state starts with our CLI prefix) from litellm.constants import LITELLM_CLI_SESSION_TOKEN_PREFIX - - if state and state.startswith(f"{LITELLM_CLI_SESSION_TOKEN_PREFIX}:"): - # Extract the key ID from the state - key_id = state.split(":", 1)[1] - - # Get existing_key from query parameters if provided - existing_key = request.query_params.get("existing_key") - - verbose_proxy_logger.info(f"CLI SSO callback detected for key: {key_id}, existing_key: {existing_key}") - return await cli_sso_callback(request, key=key_id, existing_key=existing_key) - from litellm.proxy._types import LiteLLM_JWTAuth from litellm.proxy.auth.handle_jwt import JWTHandler from litellm.proxy.proxy_server import ( @@ -674,6 +664,17 @@ async def auth_callback(request: Request, state: Optional[str] = None): # noqa: status_code=401, detail="Result not returned by SSO provider.", ) + + + if state and state.startswith(f"{LITELLM_CLI_SESSION_TOKEN_PREFIX}:"): + # Extract the key ID from the state + key_id = state.split(":", 1)[1] + + # Get existing_key from query parameters if provided + existing_key = request.query_params.get("existing_key") + + verbose_proxy_logger.info(f"CLI SSO callback detected for key: {key_id}, existing_key: {existing_key}") + return await cli_sso_callback(request=request, key=key_id, existing_key=existing_key, result=result) return await SSOAuthenticationHandler.get_redirect_response_from_openid( result=result, @@ -684,7 +685,7 @@ async def auth_callback(request: Request, state: Optional[str] = None): # noqa: ) -async def _regenerate_cli_key(existing_key: str, new_key: str) -> None: +async def _regenerate_cli_key(existing_key: str, new_key: str, user_id: Optional[str] = None) -> None: """Regenerate an existing CLI key with a new token""" from litellm.proxy._types import RegenerateKeyRequest, UserAPIKeyAuth from litellm.proxy.management_endpoints.key_management_endpoints import ( @@ -698,7 +699,8 @@ async def _regenerate_cli_key(existing_key: str, new_key: str) -> None: regenerate_request = RegenerateKeyRequest( key=existing_key, new_key=new_key, - duration="24hr" + duration="24hr", + user_id=user_id, ) await regenerate_key_fn( @@ -710,7 +712,10 @@ async def _regenerate_cli_key(existing_key: str, new_key: str) -> None: verbose_proxy_logger.info(f"Regenerated CLI key: {new_key}") -async def _create_new_cli_key(key: str) -> None: +async def _create_new_cli_key( + key: str, + user_id: Optional[str] = None, +) -> None: """Create a new CLI key""" from litellm.proxy.management_endpoints.key_management_endpoints import ( generate_key_helper_fn, @@ -725,6 +730,7 @@ async def _create_new_cli_key(key: str) -> None: aliases={}, config={}, spend=0, + user_id=user_id, team_id="litellm-cli", table_name="key", token=key, @@ -733,7 +739,7 @@ async def _create_new_cli_key(key: str) -> None: verbose_proxy_logger.info(f"Created new CLI key: {key}") -async def cli_sso_callback(request: Request, key: Optional[str] = None, existing_key: Optional[str] = None): +async def cli_sso_callback(request: Request, key: Optional[str] = None, existing_key: Optional[str] = None, result: Optional[Union[OpenID, dict]] = None): """CLI SSO callback - regenerates existing CLI key or creates new one""" verbose_proxy_logger.info(f"CLI SSO callback for key: {key}, existing_key: {existing_key}") @@ -749,12 +755,22 @@ async def cli_sso_callback(request: Request, key: Optional[str] = None, existing raise HTTPException( status_code=500, detail=CommonProxyErrors.db_not_connected_error.value ) + + parsed_openid_result = SSOAuthenticationHandler._get_user_email_and_id_from_result(result=result) + verbose_proxy_logger.debug(f"parsed_openid_result: {parsed_openid_result}") try: if existing_key: - await _regenerate_cli_key(existing_key, key) + await _regenerate_cli_key( + existing_key=existing_key, + new_key=key, + user_id=parsed_openid_result.get("user_id"), + ) else: - await _create_new_cli_key(key) + await _create_new_cli_key( + key=key, + user_id=parsed_openid_result.get("user_id"), + ) # Return success page from fastapi.responses import HTMLResponse @@ -774,6 +790,7 @@ async def cli_sso_callback(request: Request, key: Optional[str] = None, existing @router.get("/sso/cli/poll/{key_id}", tags=["experimental"], include_in_schema=False) async def cli_poll_key(key_id: str): """CLI polling endpoint - checks if key exists in DB""" + from litellm.proxy._types import LiteLLM_VerificationToken from litellm.proxy.proxy_server import prisma_client if not key_id.startswith("sk-"): @@ -793,10 +810,11 @@ async def cli_poll_key(key_id: str): key_obj = await prisma_client.db.litellm_verificationtoken.find_unique( where={"token": hashed_token} ) + key_obj: LiteLLM_VerificationToken = cast(LiteLLM_VerificationToken, key_obj) if key_obj: verbose_proxy_logger.info(f"CLI key found: {key_id}") - return {"status": "ready", "key": key_id} + return {"status": "ready", "key": key_id, "user_id": key_obj.user_id} else: return {"status": "pending"} @@ -1315,38 +1333,18 @@ class SSOAuthenticationHandler: return None @staticmethod - async def get_redirect_response_from_openid( # noqa: PLR0915 - result: Union[OpenID, dict, CustomOpenID], - request: Request, - received_response: Optional[dict] = None, + def _get_user_email_and_id_from_result( + result: Optional[Union[OpenID, dict]], generic_client_id: Optional[str] = None, - ui_access_mode: Optional[Dict] = None, - ) -> RedirectResponse: - import jwt - - from litellm.proxy.proxy_server import ( - general_settings, - generate_key_helper_fn, - master_key, - premium_user, - proxy_logging_obj, - user_api_key_cache, - user_custom_sso, - ) - from litellm.proxy.utils import get_prisma_client_or_throw - from litellm.types.proxy.ui_sso import ReturnedUITokenObject - - prisma_client = get_prisma_client_or_throw( - "Prisma client is None, connect a database to your proxy" - ) - - # User is Authe'd in - generate key for the UI to access Proxy - verbose_proxy_logger.info(f"SSO callback result: {result}") - + ) -> ParsedOpenIDResult: + """ + Gets the user email and id from the OpenID result after validating the email domain + """ user_email: Optional[str] = getattr(result, "email", None) user_id: Optional[str] = ( getattr(result, "id", None) if result is not None else None ) + user_role: Optional[str] = None if user_email is not None and os.getenv("ALLOWED_EMAIL_DOMAINS") is not None: email_domain = user_email.split("@")[1] @@ -1377,6 +1375,46 @@ class SSOAuthenticationHandler: if user_email is not None and (user_id is None or len(user_id) == 0): user_id = user_email + + return ParsedOpenIDResult( + user_email=user_email, + user_id=user_id, + user_role=user_role, + ) + + @staticmethod + async def get_redirect_response_from_openid( # noqa: PLR0915 + result: Union[OpenID, dict, CustomOpenID], + request: Request, + received_response: Optional[dict] = None, + generic_client_id: Optional[str] = None, + ui_access_mode: Optional[Dict] = None, + ) -> RedirectResponse: + import jwt + + from litellm.proxy.proxy_server import ( + general_settings, + generate_key_helper_fn, + master_key, + premium_user, + proxy_logging_obj, + user_api_key_cache, + user_custom_sso, + ) + from litellm.proxy.utils import get_prisma_client_or_throw + from litellm.types.proxy.ui_sso import ReturnedUITokenObject + + prisma_client = get_prisma_client_or_throw( + "Prisma client is None, connect a database to your proxy" + ) + + # User is Authe'd in - generate key for the UI to access Proxy + parsed_openid_result = SSOAuthenticationHandler._get_user_email_and_id_from_result(result=result, generic_client_id=generic_client_id) + user_email = parsed_openid_result.get("user_email") + user_id = parsed_openid_result.get("user_id") + user_role = parsed_openid_result.get("user_role") + verbose_proxy_logger.info(f"SSO callback result: {result}") + user_info = None user_id_models: List = [] diff --git a/litellm/proxy/proxy_config.yaml b/litellm/proxy/proxy_config.yaml index fdd2e51dfde..addf2443d36 100644 --- a/litellm/proxy/proxy_config.yaml +++ b/litellm/proxy/proxy_config.yaml @@ -11,3 +11,15 @@ model_list: aws_batch_role_arn: arn:aws:iam::888602223428:role/service-role/AmazonBedrockExecutionRoleForAgents_BB9HNW6V4CV model_info: mode: batch + - model_name: anthropic/* + litellm_params: + model: anthropic/* + api_key: os.environ/ANTHROPIC_API_KEY + - model_name: openai/* + litellm_params: + model: openai/* + api_key: os.environ/OPENAI_API_KEY + - model_name: gemini/* + litellm_params: + model: gemini/* + api_key: os.environ/GEMINI_API_KEY diff --git a/litellm/types/proxy/ui_sso.py b/litellm/types/proxy/ui_sso.py index 0c036274c79..04523e88b1b 100644 --- a/litellm/types/proxy/ui_sso.py +++ b/litellm/types/proxy/ui_sso.py @@ -17,3 +17,12 @@ class ReturnedUITokenObject(TypedDict): auth_header_name: str disabled_non_admin_personal_key_creation: bool server_root_path: str # e.g. `/litellm` + + +class ParsedOpenIDResult(TypedDict, total=False): + """ + Parsed OpenID result + """ + user_email: Optional[str] + user_id: Optional[str] + user_role: Optional[str] \ No newline at end of file diff --git a/litellm/utils.py b/litellm/utils.py index 515b6990eee..ee5f9753943 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -59,12 +59,6 @@ import litellm.litellm_core_utils.audio_utils.utils import litellm.litellm_core_utils.json_validation_rule import litellm.llms import litellm.llms.gemini -# Import cached imports utilities -from litellm.litellm_core_utils.cached_imports import ( - get_coroutine_checker, - get_litellm_logging_class, - get_set_callbacks, -) from litellm.caching._internal_lru_cache import lru_cache_wrapper from litellm.caching.caching import DualCache from litellm.caching.caching_handler import CachingHandlerResponse, LLMCachingHandler @@ -87,6 +81,13 @@ from litellm.integrations.custom_logger import CustomLogger from litellm.integrations.vector_store_integrations.base_vector_store import ( BaseVectorStore, ) + +# Import cached imports utilities +from litellm.litellm_core_utils.cached_imports import ( + get_coroutine_checker, + get_litellm_logging_class, + get_set_callbacks, +) from litellm.litellm_core_utils.core_helpers import ( map_finish_reason, process_response_headers, @@ -228,7 +229,6 @@ from typing import ( get_args, ) - from openai import OpenAIError as OriginalError from litellm.litellm_core_utils.thread_pool_executor import executor @@ -6422,6 +6422,8 @@ def get_valid_models( check_provider_endpoint: Optional[bool] = None, custom_llm_provider: Optional[str] = None, litellm_params: Optional[LiteLLM_Params] = None, + api_key: Optional[str] = None, + api_base: Optional[str] = None, ) -> List[str]: """ Returns a list of valid LLMs based on the set environment variables @@ -6429,11 +6431,24 @@ def get_valid_models( Args: check_provider_endpoint: If True, will check the provider's endpoint for valid models. custom_llm_provider: If provided, will only check the provider's endpoint for valid models. + api_key: If provided, will use the API key to get valid models. + api_base: If provided, will use the API base to get valid models. Returns: A list of valid LLMs """ try: + ################################ + # init litellm_params + ################################# + if litellm_params is None: + litellm_params = LiteLLM_Params(model="") + if api_key is not None: + litellm_params.api_key = api_key + if api_base is not None: + litellm_params.api_base = api_base + ################################# + check_provider_endpoint = ( check_provider_endpoint or litellm.check_provider_endpoint ) diff --git a/tests/test_litellm/litellm_core_utils/test_cli_token_utils.py b/tests/test_litellm/litellm_core_utils/test_cli_token_utils.py new file mode 100644 index 00000000000..fe78b6ecfe3 --- /dev/null +++ b/tests/test_litellm/litellm_core_utils/test_cli_token_utils.py @@ -0,0 +1,69 @@ +""" +Unit tests for CLI token utilities +""" + +import json +import os +import tempfile +from pathlib import Path +from unittest.mock import mock_open, patch + +import pytest + +from litellm.litellm_core_utils.cli_token_utils import get_litellm_gateway_api_key + + +class TestCLITokenUtils: + """Test CLI token utility functions""" + + def test_get_litellm_gateway_api_key_success(self): + """Test getting CLI API key when token file exists and is valid""" + token_data = { + 'key': 'sk-test-cli-key-123', + 'user_id': 'test-user', + 'user_email': 'test@example.com', + 'timestamp': 1234567890 + } + + with patch('os.path.exists', return_value=True), \ + patch('builtins.open', mock_open(read_data=json.dumps(token_data))), \ + patch('litellm.litellm_core_utils.cli_token_utils.get_cli_token_file_path', return_value='/test/.litellm/token.json'): + + result = get_litellm_gateway_api_key() + + assert result == 'sk-test-cli-key-123' + + def test_get_litellm_gateway_api_key_no_file(self): + """Test getting CLI API key when token file doesn't exist""" + with patch('os.path.exists', return_value=False), \ + patch('litellm.litellm_core_utils.cli_token_utils.get_cli_token_file_path', return_value='/test/.litellm/token.json'): + + result = get_litellm_gateway_api_key() + + assert result is None + + def test_get_litellm_gateway_api_key_invalid_json(self): + """Test getting CLI API key when token file has invalid JSON""" + with patch('os.path.exists', return_value=True), \ + patch('builtins.open', mock_open(read_data='invalid json')), \ + patch('litellm.litellm_core_utils.cli_token_utils.get_cli_token_file_path', return_value='/test/.litellm/token.json'): + + result = get_litellm_gateway_api_key() + + assert result is None + + def test_get_litellm_gateway_api_key_no_key_field(self): + """Test getting CLI API key when token file exists but has no key field""" + token_data = { + 'user_id': 'test-user', + 'user_email': 'test@example.com' + # Missing 'key' field + } + + with patch('os.path.exists', return_value=True), \ + patch('builtins.open', mock_open(read_data=json.dumps(token_data))), \ + patch('litellm.litellm_core_utils.cli_token_utils.get_cli_token_file_path', return_value='/test/.litellm/token.json'): + + result = get_litellm_gateway_api_key() + + assert result is None diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py index fc7088f4b62..618a2902540 100644 --- a/tests/test_litellm/test_utils.py +++ b/tests/test_litellm/test_utils.py @@ -2409,3 +2409,53 @@ def test_model_info_for_vertex_ai_deepseek_model(): assert model_info["input_cost_per_token"] is not None assert model_info["output_cost_per_token"] is not None print("vertex deepseek model info", model_info) + + +class TestGetValidModelsWithCLI: + """Test get_valid_models function as used in CLI token usage""" + + def test_get_valid_models_with_cli_pattern(self): + """Test get_valid_models with litellm_proxy provider and CLI token pattern""" + + # Mock the HTTP request that get_valid_models makes to the proxy + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "data": [ + {"id": "gpt-3.5-turbo", "object": "model"}, + {"id": "gpt-4", "object": "model"}, + {"id": "litellm_proxy/gemini/gemini-2.5-flash", "object": "model"}, + {"id": "claude-3-sonnet", "object": "model"} + ] + } + + with patch('requests.get', return_value=mock_response) as mock_get: + # Test the exact pattern used in cli_token_usage.py + result = litellm.get_valid_models( + check_provider_endpoint=True, + custom_llm_provider="litellm_proxy", + api_key="sk-test-cli-key-123", + api_base="http://localhost:4000/" + ) + + # Verify the function returns a list of model names + assert isinstance(result, list) + assert len(result) == 4 + assert "gpt-3.5-turbo" in result + assert "gpt-4" in result + assert "litellm_proxy/gemini/gemini-2.5-flash" in result + assert "claude-3-sonnet" in result + + # Verify the HTTP request was made with correct parameters + mock_get.assert_called_once() + call_args = mock_get.call_args + + # Check that the request was made to the correct endpoint + assert "http://localhost:4000/" in call_args[0][0] + assert "/v1/models" in call_args[0][0] + + # Check that the API key was included in headers + assert "headers" in call_args.kwargs + headers = call_args.kwargs["headers"] + assert "Authorization" in headers + assert "Bearer sk-test-cli-key-123" == headers["Authorization"] From a5ed3446cde352b9a21d52f22cdbbd2ddaaef81d Mon Sep 17 00:00:00 2001 From: Gagan Raj Chinka <159694330+CH-GAGANRAJ@users.noreply.github.com> Date: Tue, 23 Sep 2025 09:59:15 +0530 Subject: [PATCH 42/44] opnerouter/x-ai/grok-4-fast:free" to model_prices_and_context_window.json (#14779) * Update model_prices_and_context_window_backup.json * Update model_prices_and_context_window.json --- .../model_prices_and_context_window_backup.json | 16 +++++++++++++++- model_prices_and_context_window.json | 16 +++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index aa30efd606e..256dfeb3718 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -16900,6 +16900,20 @@ "supports_tool_choice": true, "supports_web_search": true }, + "openrouter/x-ai/grok-4-fast:free": { + "input_cost_per_token": 0, + "litellm_provider": "openrouter", + "max_input_tokens": 2000000, + "max_output_tokens": 30000, + "max_tokens": 2000000, + "mode": "chat", + "output_cost_per_token": 0, + "source": "https://openrouter.ai/x-ai/grok-4-fast:free", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_web_search": false + }, "ovhcloud/DeepSeek-R1-Distill-Llama-70B": { "input_cost_per_token": 6.7e-07, "litellm_provider": "ovhcloud", @@ -21463,4 +21477,4 @@ "supports_vision": true, "supports_web_search": true } -} \ No newline at end of file +} diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index aa30efd606e..256dfeb3718 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -16900,6 +16900,20 @@ "supports_tool_choice": true, "supports_web_search": true }, + "openrouter/x-ai/grok-4-fast:free": { + "input_cost_per_token": 0, + "litellm_provider": "openrouter", + "max_input_tokens": 2000000, + "max_output_tokens": 30000, + "max_tokens": 2000000, + "mode": "chat", + "output_cost_per_token": 0, + "source": "https://openrouter.ai/x-ai/grok-4-fast:free", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_web_search": false + }, "ovhcloud/DeepSeek-R1-Distill-Llama-70B": { "input_cost_per_token": 6.7e-07, "litellm_provider": "ovhcloud", @@ -21463,4 +21477,4 @@ "supports_vision": true, "supports_web_search": true } -} \ No newline at end of file +} From d2208023d20c447b8f1ee2daefabe3dcf7c5cff6 Mon Sep 17 00:00:00 2001 From: Sameerlite Date: Tue, 23 Sep 2025 10:48:27 +0530 Subject: [PATCH 43/44] Add service_tier based pricing support for openai --- litellm/cost_calculator.py | 15 +- litellm/litellm_core_utils/litellm_logging.py | 1 + .../litellm_core_utils/llm_cost_calc/utils.py | 68 +++++++- litellm/llms/openai/cost_calculation.py | 4 +- ...odel_prices_and_context_window_backup.json | 59 +++++++ litellm/types/utils.py | 12 ++ litellm/utils.py | 6 + model_prices_and_context_window.json | 61 +++++++ .../llm_cost_calc/test_llm_cost_calc_utils.py | 163 ++++++++++++++++++ 9 files changed, 378 insertions(+), 11 deletions(-) diff --git a/litellm/cost_calculator.py b/litellm/cost_calculator.py index 5d8f5faadf1..36a562b3574 100644 --- a/litellm/cost_calculator.py +++ b/litellm/cost_calculator.py @@ -148,6 +148,8 @@ def cost_per_token( # noqa: PLR0915 ### CALL TYPE ### call_type: CallTypesLiteral = "completion", audio_transcription_file_duration: float = 0.0, # for audio transcription calls - the file time in seconds + ### SERVICE TIER ### + service_tier: Optional[str] = None, # for OpenAI service tier pricing ) -> Tuple[float, float]: # type: ignore """ Calculates the cost per token for a given model, prompt tokens, and completion tokens. @@ -278,6 +280,7 @@ def cost_per_token( # noqa: PLR0915 model=model_without_prefix, usage=usage_block, custom_llm_provider=custom_llm_provider, + service_tier=service_tier, ) return prompt_cost, completion_cost @@ -327,7 +330,7 @@ def cost_per_token( # noqa: PLR0915 elif custom_llm_provider == "bedrock": return bedrock_cost_per_token(model=model, usage=usage_block) elif custom_llm_provider == "openai": - return openai_cost_per_token(model=model, usage=usage_block) + return openai_cost_per_token(model=model, usage=usage_block, service_tier=service_tier) elif custom_llm_provider == "databricks": return databricks_cost_per_token(model=model, usage=usage_block) elif custom_llm_provider == "fireworks_ai": @@ -606,6 +609,8 @@ def completion_cost( # noqa: PLR0915 litellm_model_name: Optional[str] = None, router_model_id: Optional[str] = None, litellm_logging_obj: Optional[LitellmLoggingObject] = None, + ### SERVICE TIER ### + service_tier: Optional[str] = None, # for OpenAI service tier pricing ) -> float: """ Calculate the cost of a given completion call fot GPT-3.5-turbo, llama2, any litellm supported llm. @@ -658,6 +663,10 @@ def completion_cost( # noqa: PLR0915 completion_response=completion_response ) rerank_billed_units: Optional[RerankBilledUnits] = None + + # Extract service_tier from optional_params if not provided directly + if service_tier is None and optional_params is not None: + service_tier = optional_params.get("service_tier") selected_model = _select_model_name_for_cost_calc( model=model, @@ -909,6 +918,7 @@ def completion_cost( # noqa: PLR0915 call_type=cast(CallTypesLiteral, call_type), audio_transcription_file_duration=audio_transcription_file_duration, rerank_billed_units=rerank_billed_units, + service_tier=service_tier, ) _final_cost = ( prompt_tokens_cost_usd_dollar + completion_tokens_cost_usd_dollar @@ -1003,6 +1013,8 @@ def response_cost_calculator( litellm_model_name: Optional[str] = None, router_model_id: Optional[str] = None, litellm_logging_obj: Optional[LitellmLoggingObject] = None, + ### SERVICE TIER ### + service_tier: Optional[str] = None, # for OpenAI service tier pricing ) -> float: """ Returns @@ -1036,6 +1048,7 @@ def response_cost_calculator( litellm_model_name=litellm_model_name, router_model_id=router_model_id, litellm_logging_obj=litellm_logging_obj, + service_tier=service_tier, ) return response_cost except Exception as e: diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index 059fd9f1fcf..65f665041f9 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -1228,6 +1228,7 @@ class Logging(LiteLLMLoggingBaseClass): "standard_built_in_tools_params": self.standard_built_in_tools_params, "router_model_id": router_model_id, "litellm_logging_obj": self, + "service_tier": self.optional_params.get("service_tier") if self.optional_params else None, } except Exception as e: # error creating kwargs for cost calculation debug_info = StandardLoggingModelCostFailureDebugInformation( diff --git a/litellm/litellm_core_utils/llm_cost_calc/utils.py b/litellm/litellm_core_utils/llm_cost_calc/utils.py index 60a31198415..1753ed2d1ce 100644 --- a/litellm/litellm_core_utils/llm_cost_calc/utils.py +++ b/litellm/litellm_core_utils/llm_cost_calc/utils.py @@ -4,7 +4,7 @@ from typing import Any, Literal, Optional, Tuple, TypedDict, cast import litellm -from litellm._logging import verbose_logger +from litellm._logging import verbose_logger, verbose_proxy_logger from litellm.types.utils import ( CacheCreationTokenDetails, CallTypes, @@ -12,6 +12,7 @@ from litellm.types.utils import ( ModelInfo, PassthroughCallTypes, Usage, + ServiceTier, ) from litellm.utils import get_model_info @@ -114,8 +115,30 @@ def _generic_cost_per_character( return prompt_cost, completion_cost +def _get_service_tier_cost_key(base_key: str, service_tier: Optional[str]) -> str: + """ + Get the appropriate cost key based on service tier. + + Args: + base_key: The base cost key (e.g., "input_cost_per_token") + service_tier: The service tier ("flex", "priority", or None for standard) + + Returns: + str: The cost key to use (e.g., "input_cost_per_token_flex" or "input_cost_per_token") + """ + if service_tier is None: + return base_key + + # Only use service tier specific keys for "flex" and "priority" + if service_tier.lower() in [ServiceTier.FLEX.value, ServiceTier.PRIORITY.value]: + return f"{base_key}_{service_tier.lower()}" + + # For any other service tier, use standard pricing + return base_key + + def _get_token_base_cost( - model_info: ModelInfo, usage: Usage + model_info: ModelInfo, usage: Usage, service_tier: Optional[str] = None ) -> Tuple[float, float, float, float, float]: """ Return prompt cost, completion cost, and cache costs for a given model and usage. @@ -126,21 +149,27 @@ def _get_token_base_cost( Returns: Tuple[float, float, float, float] - (prompt_cost, completion_cost, cache_creation_cost, cache_read_cost) """ + # Get service tier aware cost keys + input_cost_key = _get_service_tier_cost_key("input_cost_per_token", service_tier) + output_cost_key = _get_service_tier_cost_key("output_cost_per_token", service_tier) + cache_creation_cost_key = _get_service_tier_cost_key("cache_creation_input_token_cost", service_tier) + cache_read_cost_key = _get_service_tier_cost_key("cache_read_input_token_cost", service_tier) + prompt_base_cost = cast( - float, _get_cost_per_unit(model_info, "input_cost_per_token") + float, _get_cost_per_unit(model_info, input_cost_key) ) completion_base_cost = cast( - float, _get_cost_per_unit(model_info, "output_cost_per_token") + float, _get_cost_per_unit(model_info, output_cost_key) ) cache_creation_cost = cast( - float, _get_cost_per_unit(model_info, "cache_creation_input_token_cost") + float, _get_cost_per_unit(model_info, cache_creation_cost_key) ) cache_creation_cost_above_1hr = cast( float, _get_cost_per_unit(model_info, "cache_creation_input_token_cost_above_1hr"), ) cache_read_cost = cast( - float, _get_cost_per_unit(model_info, "cache_read_input_token_cost") + float, _get_cost_per_unit(model_info, cache_read_cost_key) ) ## CHECK IF ABOVE THRESHOLD @@ -249,6 +278,29 @@ def _get_cost_per_unit( verbose_logger.exception( f"litellm.litellm_core_utils.llm_cost_calc.utils.py::calculate_cost_per_component(): Exception occured - {cost_per_unit}\nDefaulting to 0.0" ) + + # If the service tier key doesn't exist or is None, try to fall back to the standard key + if cost_per_unit is None: + # Check if any service tier suffix exists in the cost key using ServiceTier enum + for service_tier in ServiceTier: + suffix = f"_{service_tier.value}" + if suffix in cost_key: + # Extract the base key by removing the matched suffix + base_key = cost_key.replace(suffix, '') + fallback_cost = model_info.get(base_key) + if isinstance(fallback_cost, float): + return fallback_cost + if isinstance(fallback_cost, int): + return float(fallback_cost) + if isinstance(fallback_cost, str): + try: + return float(fallback_cost) + except ValueError: + verbose_logger.exception( + f"litellm.litellm_core_utils.llm_cost_calc.utils.py::_get_cost_per_unit(): Exception occured - {fallback_cost}\nDefaulting to 0.0" + ) + break # Only try the first matching suffix + return default_value @@ -443,7 +495,7 @@ def _calculate_input_cost( def generic_cost_per_token( - model: str, usage: Usage, custom_llm_provider: str + model: str, usage: Usage, custom_llm_provider: str, service_tier: Optional[str] = None ) -> Tuple[float, float]: """ Calculates the cost per token for a given model, prompt tokens, and completion tokens. @@ -495,7 +547,7 @@ def generic_cost_per_token( cache_creation_cost, cache_creation_cost_above_1hr, cache_read_cost, - ) = _get_token_base_cost(model_info=model_info, usage=usage) + ) = _get_token_base_cost(model_info=model_info, usage=usage, service_tier=service_tier) prompt_cost = _calculate_input_cost( prompt_tokens_details=prompt_tokens_details, diff --git a/litellm/llms/openai/cost_calculation.py b/litellm/llms/openai/cost_calculation.py index 304c444e37a..229f75f2657 100644 --- a/litellm/llms/openai/cost_calculation.py +++ b/litellm/llms/openai/cost_calculation.py @@ -18,7 +18,7 @@ def cost_router(call_type: CallTypes) -> Literal["cost_per_token", "cost_per_sec return "cost_per_token" -def cost_per_token(model: str, usage: Usage) -> Tuple[float, float]: +def cost_per_token(model: str, usage: Usage, service_tier: Optional[str] = None) -> Tuple[float, float]: """ Calculates the cost per token for a given model, prompt tokens, and completion tokens. @@ -31,7 +31,7 @@ def cost_per_token(model: str, usage: Usage) -> Tuple[float, float]: """ ## CALCULATE INPUT COST return generic_cost_per_token( - model=model, usage=usage, custom_llm_provider="openai" + model=model, usage=usage, custom_llm_provider="openai", service_tier=service_tier ) # ### Non-cached text tokens # non_cached_text_tokens = usage.prompt_tokens diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 43ea3af320f..f8f2bdf5ad5 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -11534,8 +11534,10 @@ }, "gpt-4.1": { "cache_read_input_token_cost": 5e-07, + "cache_read_input_token_cost_priority": 8.75e-07, "input_cost_per_token": 2e-06, "input_cost_per_token_batches": 1e-06, + "input_cost_per_token_priority": 3.5e-06, "litellm_provider": "openai", "max_input_tokens": 1047576, "max_output_tokens": 32768, @@ -11543,6 +11545,7 @@ "mode": "chat", "output_cost_per_token": 8e-06, "output_cost_per_token_batches": 4e-06, + "output_cost_per_token_priority": 1.4e-05, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -11600,8 +11603,10 @@ }, "gpt-4.1-mini": { "cache_read_input_token_cost": 1e-07, + "cache_read_input_token_cost_priority": 1.75e-07, "input_cost_per_token": 4e-07, "input_cost_per_token_batches": 2e-07, + "input_cost_per_token_priority": 7e-07, "litellm_provider": "openai", "max_input_tokens": 1047576, "max_output_tokens": 32768, @@ -11609,6 +11614,7 @@ "mode": "chat", "output_cost_per_token": 1.6e-06, "output_cost_per_token_batches": 8e-07, + "output_cost_per_token_priority": 2.8e-06, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -11666,8 +11672,10 @@ }, "gpt-4.1-nano": { "cache_read_input_token_cost": 2.5e-08, + "cache_read_input_token_cost_priority": 5e-08, "input_cost_per_token": 1e-07, "input_cost_per_token_batches": 5e-08, + "input_cost_per_token_priority": 2e-07, "litellm_provider": "openai", "max_input_tokens": 1047576, "max_output_tokens": 32768, @@ -11675,6 +11683,7 @@ "mode": "chat", "output_cost_per_token": 4e-07, "output_cost_per_token_batches": 2e-07, + "output_cost_per_token_priority": 8e-07, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -11773,8 +11782,10 @@ }, "gpt-4o": { "cache_read_input_token_cost": 1.25e-06, + "cache_read_input_token_cost_priority": 2.125e-06, "input_cost_per_token": 2.5e-06, "input_cost_per_token_batches": 1.25e-06, + "input_cost_per_token_priority": 4.25e-06, "litellm_provider": "openai", "max_input_tokens": 128000, "max_output_tokens": 16384, @@ -11782,6 +11793,7 @@ "mode": "chat", "output_cost_per_token": 1e-05, "output_cost_per_token_batches": 5e-06, + "output_cost_per_token_priority": 1.7e-05, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, @@ -11794,6 +11806,7 @@ "gpt-4o-2024-05-13": { "input_cost_per_token": 5e-06, "input_cost_per_token_batches": 2.5e-06, + "input_cost_per_token_priority": 8.75e-06, "litellm_provider": "openai", "max_input_tokens": 128000, "max_output_tokens": 4096, @@ -11801,6 +11814,7 @@ "mode": "chat", "output_cost_per_token": 1.5e-05, "output_cost_per_token_batches": 7.5e-06, + "output_cost_per_token_priority": 2.625e-05, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, @@ -11919,8 +11933,10 @@ }, "gpt-4o-mini": { "cache_read_input_token_cost": 7.5e-08, + "cache_read_input_token_cost_priority": 1.25e-07, "input_cost_per_token": 1.5e-07, "input_cost_per_token_batches": 7.5e-08, + "input_cost_per_token_priority": 2.5e-07, "litellm_provider": "openai", "max_input_tokens": 128000, "max_output_tokens": 16384, @@ -11928,6 +11944,7 @@ "mode": "chat", "output_cost_per_token": 6e-07, "output_cost_per_token_batches": 3e-07, + "output_cost_per_token_priority": 1e-06, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, @@ -12243,13 +12260,19 @@ }, "gpt-5": { "cache_read_input_token_cost": 1.25e-07, + "cache_read_input_token_cost_flex": 6.25e-08, + "cache_read_input_token_cost_priority": 2.5e-07, "input_cost_per_token": 1.25e-06, + "input_cost_per_token_flex": 6.25e-07, + "input_cost_per_token_priority": 2.5e-06, "litellm_provider": "openai", "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1e-05, + "output_cost_per_token_flex": 5e-06, + "output_cost_per_token_priority": 2e-05, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -12275,13 +12298,19 @@ }, "gpt-5-2025-08-07": { "cache_read_input_token_cost": 1.25e-07, + "cache_read_input_token_cost_flex": 6.25e-08, + "cache_read_input_token_cost_priority": 2.5e-07, "input_cost_per_token": 1.25e-06, + "input_cost_per_token_flex": 6.25e-07, + "input_cost_per_token_priority": 2.5e-06, "litellm_provider": "openai", "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1e-05, + "output_cost_per_token_flex": 5e-06, + "output_cost_per_token_priority": 2e-05, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -12371,13 +12400,19 @@ }, "gpt-5-mini": { "cache_read_input_token_cost": 2.5e-08, + "cache_read_input_token_cost_flex": 1.25e-08, + "cache_read_input_token_cost_priority": 4.5e-08, "input_cost_per_token": 2.5e-07, + "input_cost_per_token_flex": 1.25e-07, + "input_cost_per_token_priority": 4.5e-07, "litellm_provider": "openai", "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 2e-06, + "output_cost_per_token_flex": 1e-06, + "output_cost_per_token_priority": 3.6e-06, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -12403,13 +12438,19 @@ }, "gpt-5-mini-2025-08-07": { "cache_read_input_token_cost": 2.5e-08, + "cache_read_input_token_cost_flex": 1.25e-08, + "cache_read_input_token_cost_priority": 4.5e-08, "input_cost_per_token": 2.5e-07, + "input_cost_per_token_flex": 1.25e-07, + "input_cost_per_token_priority": 4.5e-07, "litellm_provider": "openai", "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 2e-06, + "output_cost_per_token_flex": 1e-06, + "output_cost_per_token_priority": 3.6e-06, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -12435,13 +12476,16 @@ }, "gpt-5-nano": { "cache_read_input_token_cost": 5e-09, + "cache_read_input_token_cost_flex": 2.5e-09, "input_cost_per_token": 5e-08, + "input_cost_per_token_flex": 2.5e-08, "litellm_provider": "openai", "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 4e-07, + "output_cost_per_token_flex": 2e-07, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -12467,13 +12511,16 @@ }, "gpt-5-nano-2025-08-07": { "cache_read_input_token_cost": 5e-09, + "cache_read_input_token_cost_flex": 2.5e-09, "input_cost_per_token": 5e-08, + "input_cost_per_token_flex": 2.5e-08, "litellm_provider": "openai", "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 4e-07, + "output_cost_per_token_flex": 2e-07, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -15177,13 +15224,19 @@ }, "o3": { "cache_read_input_token_cost": 5e-07, + "cache_read_input_token_cost_flex": 2.5e-07, + "cache_read_input_token_cost_priority": 8.75e-07, "input_cost_per_token": 2e-06, + "input_cost_per_token_flex": 1e-06, + "input_cost_per_token_priority": 3.5e-06, "litellm_provider": "openai", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 100000, "mode": "chat", "output_cost_per_token": 8e-06, + "output_cost_per_token_flex": 4e-06, + "output_cost_per_token_priority": 1.4e-05, "supported_endpoints": [ "/v1/responses", "/v1/chat/completions", @@ -15399,13 +15452,19 @@ }, "o4-mini": { "cache_read_input_token_cost": 2.75e-07, + "cache_read_input_token_cost_flex": 1.38e-07, + "cache_read_input_token_cost_priority": 5e-07, "input_cost_per_token": 1.1e-06, + "input_cost_per_token_flex": 5.5e-07, + "input_cost_per_token_priority": 2e-06, "litellm_provider": "openai", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 100000, "mode": "chat", "output_cost_per_token": 4.4e-06, + "output_cost_per_token_flex": 2.2e-06, + "output_cost_per_token_priority": 8e-06, "supports_function_calling": true, "supports_parallel_function_calling": false, "supports_pdf_input": true, diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 25f353b3bc3..7b8ddf45c8c 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -122,9 +122,13 @@ class ModelInfoBase(ProviderSpecificModelInfo, total=False): max_input_tokens: Required[Optional[int]] max_output_tokens: Required[Optional[int]] input_cost_per_token: Required[float] + input_cost_per_token_flex: Optional[float] # OpenAI flex service tier pricing + input_cost_per_token_priority: Optional[float] # OpenAI priority service tier pricing cache_creation_input_token_cost: Optional[float] cache_creation_input_token_cost_above_1hr: Optional[float] cache_read_input_token_cost: Optional[float] + cache_read_input_token_cost_flex: Optional[float] # OpenAI flex service tier pricing + cache_read_input_token_cost_priority: Optional[float] # OpenAI priority service tier pricing input_cost_per_character: Optional[float] # only for vertex ai models input_cost_per_audio_token: Optional[float] input_cost_per_token_above_128k_tokens: Optional[float] # only for vertex ai models @@ -142,6 +146,8 @@ class ModelInfoBase(ProviderSpecificModelInfo, total=False): input_cost_per_token_batches: Optional[float] output_cost_per_token_batches: Optional[float] output_cost_per_token: Required[float] + output_cost_per_token_flex: Optional[float] # OpenAI flex service tier pricing + output_cost_per_token_priority: Optional[float] # OpenAI priority service tier pricing output_cost_per_character: Optional[float] # only for vertex ai models output_cost_per_audio_token: Optional[float] output_cost_per_token_above_128k_tokens: Optional[ @@ -2583,6 +2589,12 @@ class SpecialEnums(Enum): LITELLM_MANAGED_GENERIC_RESPONSE_COMPLETE_STR = "litellm_proxy;model_id:{};generic_response_id:{}" # generic implementation of 'managed batches' - used for finetuning and any future work. +class ServiceTier(Enum): + """Enum for service tier types used in cost calculations.""" + FLEX = "flex" + PRIORITY = "priority" + + LLMResponseTypes = Union[ ModelResponse, EmbeddingResponse, diff --git a/litellm/utils.py b/litellm/utils.py index e4cbaec7065..549a982696d 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -4874,12 +4874,16 @@ def _get_model_info_helper( # noqa: PLR0915 max_input_tokens=_model_info.get("max_input_tokens", None), max_output_tokens=_model_info.get("max_output_tokens", None), input_cost_per_token=_input_cost_per_token, + input_cost_per_token_flex=_model_info.get("input_cost_per_token_flex", None), + input_cost_per_token_priority=_model_info.get("input_cost_per_token_priority", None), cache_creation_input_token_cost=_model_info.get( "cache_creation_input_token_cost", None ), cache_read_input_token_cost=_model_info.get( "cache_read_input_token_cost", None ), + cache_read_input_token_cost_flex=_model_info.get("cache_read_input_token_cost_flex", None), + cache_read_input_token_cost_priority=_model_info.get("cache_read_input_token_cost_priority", None), cache_creation_input_token_cost_above_1hr=_model_info.get( "cache_creation_input_token_cost_above_1hr", None ), @@ -4904,6 +4908,8 @@ def _get_model_info_helper( # noqa: PLR0915 "output_cost_per_token_batches" ), output_cost_per_token=_output_cost_per_token, + output_cost_per_token_flex=_model_info.get("output_cost_per_token_flex", None), + output_cost_per_token_priority=_model_info.get("output_cost_per_token_priority", None), output_cost_per_audio_token=_model_info.get( "output_cost_per_audio_token", None ), diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 43ea3af320f..f33bcb2a3ca 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -11534,8 +11534,10 @@ }, "gpt-4.1": { "cache_read_input_token_cost": 5e-07, + "cache_read_input_token_cost_priority": 8.75e-07, "input_cost_per_token": 2e-06, "input_cost_per_token_batches": 1e-06, + "input_cost_per_token_priority": 3.5e-06, "litellm_provider": "openai", "max_input_tokens": 1047576, "max_output_tokens": 32768, @@ -11543,6 +11545,7 @@ "mode": "chat", "output_cost_per_token": 8e-06, "output_cost_per_token_batches": 4e-06, + "output_cost_per_token_priority": 1.4e-05, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -11600,8 +11603,10 @@ }, "gpt-4.1-mini": { "cache_read_input_token_cost": 1e-07, + "cache_read_input_token_cost_priority": 1.75e-07, "input_cost_per_token": 4e-07, "input_cost_per_token_batches": 2e-07, + "input_cost_per_token_priority": 7e-07, "litellm_provider": "openai", "max_input_tokens": 1047576, "max_output_tokens": 32768, @@ -11609,6 +11614,7 @@ "mode": "chat", "output_cost_per_token": 1.6e-06, "output_cost_per_token_batches": 8e-07, + "output_cost_per_token_priority": 2.8e-06, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -11666,8 +11672,10 @@ }, "gpt-4.1-nano": { "cache_read_input_token_cost": 2.5e-08, + "cache_read_input_token_cost_priority": 5e-08, "input_cost_per_token": 1e-07, "input_cost_per_token_batches": 5e-08, + "input_cost_per_token_priority": 2e-07, "litellm_provider": "openai", "max_input_tokens": 1047576, "max_output_tokens": 32768, @@ -11675,6 +11683,7 @@ "mode": "chat", "output_cost_per_token": 4e-07, "output_cost_per_token_batches": 2e-07, + "output_cost_per_token_priority": 8e-07, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -11773,8 +11782,10 @@ }, "gpt-4o": { "cache_read_input_token_cost": 1.25e-06, + "cache_read_input_token_cost_priority": 2.125e-06, "input_cost_per_token": 2.5e-06, "input_cost_per_token_batches": 1.25e-06, + "input_cost_per_token_priority": 4.25e-06, "litellm_provider": "openai", "max_input_tokens": 128000, "max_output_tokens": 16384, @@ -11782,6 +11793,7 @@ "mode": "chat", "output_cost_per_token": 1e-05, "output_cost_per_token_batches": 5e-06, + "output_cost_per_token_priority": 1.7e-05, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, @@ -11794,6 +11806,7 @@ "gpt-4o-2024-05-13": { "input_cost_per_token": 5e-06, "input_cost_per_token_batches": 2.5e-06, + "input_cost_per_token_priority": 8.75e-06, "litellm_provider": "openai", "max_input_tokens": 128000, "max_output_tokens": 4096, @@ -11801,6 +11814,7 @@ "mode": "chat", "output_cost_per_token": 1.5e-05, "output_cost_per_token_batches": 7.5e-06, + "output_cost_per_token_priority": 2.625e-05, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, @@ -11919,8 +11933,10 @@ }, "gpt-4o-mini": { "cache_read_input_token_cost": 7.5e-08, + "cache_read_input_token_cost_priority": 1.25e-07, "input_cost_per_token": 1.5e-07, "input_cost_per_token_batches": 7.5e-08, + "input_cost_per_token_priority": 2.5e-07, "litellm_provider": "openai", "max_input_tokens": 128000, "max_output_tokens": 16384, @@ -11928,6 +11944,7 @@ "mode": "chat", "output_cost_per_token": 6e-07, "output_cost_per_token_batches": 3e-07, + "output_cost_per_token_priority": 1e-06, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, @@ -12243,13 +12260,19 @@ }, "gpt-5": { "cache_read_input_token_cost": 1.25e-07, + "cache_read_input_token_cost_flex": 6.25e-08, + "cache_read_input_token_cost_priority": 2.5e-07, "input_cost_per_token": 1.25e-06, + "input_cost_per_token_flex": 6.25e-07, + "input_cost_per_token_priority": 2.5e-06, "litellm_provider": "openai", "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1e-05, + "output_cost_per_token_flex": 5e-06, + "output_cost_per_token_priority": 2e-05, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -12275,13 +12298,19 @@ }, "gpt-5-2025-08-07": { "cache_read_input_token_cost": 1.25e-07, + "cache_read_input_token_cost_flex": 6.25e-08, + "cache_read_input_token_cost_priority": 2.5e-07, "input_cost_per_token": 1.25e-06, + "input_cost_per_token_flex": 6.25e-07, + "input_cost_per_token_priority": 2.5e-06, "litellm_provider": "openai", "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1e-05, + "output_cost_per_token_flex": 5e-06, + "output_cost_per_token_priority": 2e-05, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -12303,6 +12332,7 @@ "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, + "supports_service_tier": true, "supports_vision": true }, "gpt-5-chat": { @@ -12371,13 +12401,19 @@ }, "gpt-5-mini": { "cache_read_input_token_cost": 2.5e-08, + "cache_read_input_token_cost_flex": 1.25e-08, + "cache_read_input_token_cost_priority": 4.5e-08, "input_cost_per_token": 2.5e-07, + "input_cost_per_token_flex": 1.25e-07, + "input_cost_per_token_priority": 4.5e-07, "litellm_provider": "openai", "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 2e-06, + "output_cost_per_token_flex": 1e-06, + "output_cost_per_token_priority": 3.6e-06, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -12403,13 +12439,19 @@ }, "gpt-5-mini-2025-08-07": { "cache_read_input_token_cost": 2.5e-08, + "cache_read_input_token_cost_flex": 1.25e-08, + "cache_read_input_token_cost_priority": 4.5e-08, "input_cost_per_token": 2.5e-07, + "input_cost_per_token_flex": 1.25e-07, + "input_cost_per_token_priority": 4.5e-07, "litellm_provider": "openai", "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 2e-06, + "output_cost_per_token_flex": 1e-06, + "output_cost_per_token_priority": 3.6e-06, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -12435,13 +12477,17 @@ }, "gpt-5-nano": { "cache_read_input_token_cost": 5e-09, + "cache_read_input_token_cost_flex": 2.5e-09, "input_cost_per_token": 5e-08, + "input_cost_per_token_flex": 2.5e-08, + "input_cost_per_token_priority": 2.5e-06, "litellm_provider": "openai", "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 4e-07, + "output_cost_per_token_flex": 2e-07, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -12467,13 +12513,16 @@ }, "gpt-5-nano-2025-08-07": { "cache_read_input_token_cost": 5e-09, + "cache_read_input_token_cost_flex": 2.5e-09, "input_cost_per_token": 5e-08, + "input_cost_per_token_flex": 2.5e-08, "litellm_provider": "openai", "max_input_tokens": 400000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 4e-07, + "output_cost_per_token_flex": 2e-07, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -15177,13 +15226,19 @@ }, "o3": { "cache_read_input_token_cost": 5e-07, + "cache_read_input_token_cost_flex": 2.5e-07, + "cache_read_input_token_cost_priority": 8.75e-07, "input_cost_per_token": 2e-06, + "input_cost_per_token_flex": 1e-06, + "input_cost_per_token_priority": 3.5e-06, "litellm_provider": "openai", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 100000, "mode": "chat", "output_cost_per_token": 8e-06, + "output_cost_per_token_flex": 4e-06, + "output_cost_per_token_priority": 1.4e-05, "supported_endpoints": [ "/v1/responses", "/v1/chat/completions", @@ -15399,13 +15454,19 @@ }, "o4-mini": { "cache_read_input_token_cost": 2.75e-07, + "cache_read_input_token_cost_flex": 1.375e-07, + "cache_read_input_token_cost_priority": 5e-07, "input_cost_per_token": 1.1e-06, + "input_cost_per_token_flex": 5.5e-07, + "input_cost_per_token_priority": 2e-06, "litellm_provider": "openai", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 100000, "mode": "chat", "output_cost_per_token": 4.4e-06, + "output_cost_per_token_flex": 2.2e-06, + "output_cost_per_token_priority": 8e-06, "supports_function_calling": true, "supports_parallel_function_calling": false, "supports_pdf_input": true, 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 be3133ca37e..0c7a6b2942b 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 @@ -463,3 +463,166 @@ def test_calculate_cache_writing_cost(): ) assert result_zero == 0.0 + + +def test_service_tier_flex_pricing(): + """Test that flex service tier uses correct pricing (approximately 50% of standard).""" + # Set up environment for local model cost map + os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True" + litellm.model_cost = litellm.get_model_cost_map(url="") + + # Test with gpt-5-nano which has flex pricing + model = "gpt-5-nano" + custom_llm_provider = "openai" + + # Create usage object + usage = Usage( + prompt_tokens=1000, + completion_tokens=500, + total_tokens=1500 + ) + + # Test standard pricing + std_cost = generic_cost_per_token( + model=model, + usage=usage, + custom_llm_provider=custom_llm_provider, + service_tier=None + ) + std_total = std_cost[0] + std_cost[1] + + # Test flex pricing + flex_cost = generic_cost_per_token( + model=model, + usage=usage, + custom_llm_provider=custom_llm_provider, + service_tier="flex" + ) + flex_total = flex_cost[0] + flex_cost[1] + + # Verify flex is approximately 50% of standard + assert std_total > 0, "Standard cost should be greater than 0" + assert flex_total > 0, "Flex cost should be greater than 0" + + flex_ratio = flex_total / std_total + assert 0.45 <= flex_ratio <= 0.55, f"Flex pricing should be ~50% of standard, got {flex_ratio:.2f}" + + # Verify specific costs match expected values + # gpt-5-nano flex: input=2.5e-08, output=2e-07 + expected_flex_prompt = 1000 * 2.5e-08 # 0.000025 + expected_flex_completion = 500 * 2e-07 # 0.0001 + expected_flex_total = expected_flex_prompt + expected_flex_completion + + assert abs(flex_cost[0] - expected_flex_prompt) < 1e-10, f"Flex prompt cost mismatch: {flex_cost[0]} vs {expected_flex_prompt}" + assert abs(flex_cost[1] - expected_flex_completion) < 1e-10, f"Flex completion cost mismatch: {flex_cost[1]} vs {expected_flex_completion}" + assert abs(flex_total - expected_flex_total) < 1e-10, f"Flex total cost mismatch: {flex_total} vs {expected_flex_total}" + + +def test_service_tier_default_pricing(): + """Test that when no service tier is provided, standard pricing is used.""" + # Set up environment for local model cost map + os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True" + litellm.model_cost = litellm.get_model_cost_map(url="") + + # Test with gpt-5-nano + model = "gpt-5-nano" + custom_llm_provider = "openai" + + # Create usage object + usage = Usage( + prompt_tokens=1000, + completion_tokens=500, + total_tokens=1500 + ) + + # Test with no service tier (should use standard) + default_cost = generic_cost_per_token( + model=model, + usage=usage, + custom_llm_provider=custom_llm_provider, + service_tier=None + ) + + # Test with explicit standard service tier + standard_cost = generic_cost_per_token( + model=model, + usage=usage, + custom_llm_provider=custom_llm_provider, + service_tier="standard" + ) + + # Both should be identical + assert abs(default_cost[0] - standard_cost[0]) < 1e-10, "Default and standard prompt costs should be identical" + assert abs(default_cost[1] - standard_cost[1]) < 1e-10, "Default and standard completion costs should be identical" + + # Verify specific costs match expected standard values + # gpt-5-nano standard: input=5e-08, output=4e-07 + expected_standard_prompt = 1000 * 5e-08 # 0.00005 + expected_standard_completion = 500 * 4e-07 # 0.0002 + expected_standard_total = expected_standard_prompt + expected_standard_completion + + assert abs(default_cost[0] - expected_standard_prompt) < 1e-10, f"Standard prompt cost mismatch: {default_cost[0]} vs {expected_standard_prompt}" + assert abs(default_cost[1] - expected_standard_completion) < 1e-10, f"Standard completion cost mismatch: {default_cost[1]} vs {expected_standard_completion}" + + +def test_service_tier_fallback_pricing(): + """Test that when service tier is provided but model doesn't have those keys, it falls back to standard pricing.""" + # Set up environment for local model cost map + os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True" + litellm.model_cost = litellm.get_model_cost_map(url="") + + # Test with gpt-4 which doesn't have flex pricing keys + model = "gpt-4" + custom_llm_provider = "openai" + + # Create usage object + usage = Usage( + prompt_tokens=1000, + completion_tokens=500, + total_tokens=1500 + ) + + # Test standard pricing + std_cost = generic_cost_per_token( + model=model, + usage=usage, + custom_llm_provider=custom_llm_provider, + service_tier=None + ) + std_total = std_cost[0] + std_cost[1] + + # Test flex pricing (should fall back to standard since gpt-4 doesn't have flex keys) + flex_cost = generic_cost_per_token( + model=model, + usage=usage, + custom_llm_provider=custom_llm_provider, + service_tier="flex" + ) + flex_total = flex_cost[0] + flex_cost[1] + + # Test priority pricing (should fall back to standard since gpt-4 doesn't have priority keys) + priority_cost = generic_cost_per_token( + model=model, + usage=usage, + custom_llm_provider=custom_llm_provider, + service_tier="priority" + ) + priority_total = priority_cost[0] + priority_cost[1] + + # All should be identical (fallback to standard) + assert abs(std_total - flex_total) < 1e-10, f"Standard and flex costs should be identical (fallback): {std_total} vs {flex_total}" + assert abs(std_total - priority_total) < 1e-10, f"Standard and priority costs should be identical (fallback): {std_total} vs {priority_total}" + + # Verify costs are reasonable (not zero) + assert std_total > 0, "Standard cost should be greater than 0" + assert flex_total > 0, "Flex cost should be greater than 0 (fallback)" + assert priority_total > 0, "Priority cost should be greater than 0 (fallback)" + + # Verify specific costs match expected gpt-4 values + # gpt-4 standard: input=3e-05, output=6e-05 + expected_standard_prompt = 1000 * 3e-05 # 0.03 + expected_standard_completion = 500 * 6e-05 # 0.03 + expected_standard_total = expected_standard_prompt + expected_standard_completion + + assert abs(std_cost[0] - expected_standard_prompt) < 1e-10, f"Standard prompt cost mismatch: {std_cost[0]} vs {expected_standard_prompt}" + assert abs(std_cost[1] - expected_standard_completion) < 1e-10, f"Standard completion cost mismatch: {std_cost[1]} vs {expected_standard_completion}" From d58db21a87bddef36dc47d3e177b9a00539b9c89 Mon Sep 17 00:00:00 2001 From: Sameerlite Date: Tue, 23 Sep 2025 11:02:43 +0530 Subject: [PATCH 44/44] remove unused import --- litellm/litellm_core_utils/llm_cost_calc/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/litellm_core_utils/llm_cost_calc/utils.py b/litellm/litellm_core_utils/llm_cost_calc/utils.py index 1753ed2d1ce..626a3f3625f 100644 --- a/litellm/litellm_core_utils/llm_cost_calc/utils.py +++ b/litellm/litellm_core_utils/llm_cost_calc/utils.py @@ -4,7 +4,7 @@ from typing import Any, Literal, Optional, Tuple, TypedDict, cast import litellm -from litellm._logging import verbose_logger, verbose_proxy_logger +from litellm._logging import verbose_logger from litellm.types.utils import ( CacheCreationTokenDetails, CallTypes,