From dc200c34a214543983b2697fc2779c4590bcb348 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Sat, 11 Apr 2026 21:18:15 +0530 Subject: [PATCH 1/9] fix(responses): map refusal stop_reason to incomplete status in streaming (#25498) * fix(responses): map refusal stop_reason to incomplete status in streaming Fixes streaming responses API translation where Anthropic's stop_reason="refusal" was incorrectly translated to status="completed" instead of "incomplete". Root cause: build_base_response was unconditionally overwriting finish_reason with None from later chunks, losing the terminal content_filter value. Changes: - streaming_chunk_builder_utils: skip None finish_reason values in build_base_response - streaming_iterator: snapshot chunks before returning pending events (sync path) - streaming_handler: treat usage-only chunks as meaningful content - transformation: map finish_reason=refusal to status=incomplete - tests: add regression tests for refusal handling Made-with: Cursor * Fix test --- .../streaming_chunk_builder_utils.py | 7 +- .../litellm_core_utils/streaming_handler.py | 6 +- .../streaming_iterator.py | 10 +- .../transformation.py | 4 +- .../test_streaming_handler.py | 94 ++++++-- .../test_litellm_completion_responses.py | 202 +++++++++++++----- 6 files changed, 237 insertions(+), 86 deletions(-) diff --git a/litellm/litellm_core_utils/streaming_chunk_builder_utils.py b/litellm/litellm_core_utils/streaming_chunk_builder_utils.py index 1935372e5df..f909111a05c 100644 --- a/litellm/litellm_core_utils/streaming_chunk_builder_utils.py +++ b/litellm/litellm_core_utils/streaming_chunk_builder_utils.py @@ -123,10 +123,13 @@ class ChunkProcessor: finish_reason = "stop" for chunk in chunks: if "choices" in chunk and len(chunk["choices"]) > 0: + chunk_finish_reason = None if hasattr(chunk["choices"][0], "finish_reason"): - finish_reason = chunk["choices"][0].finish_reason + chunk_finish_reason = chunk["choices"][0].finish_reason elif "finish_reason" in chunk["choices"][0]: - finish_reason = chunk["choices"][0]["finish_reason"] + chunk_finish_reason = chunk["choices"][0]["finish_reason"] + if chunk_finish_reason is not None: + finish_reason = chunk_finish_reason # Initialize the response dictionary response = ModelResponse( diff --git a/litellm/litellm_core_utils/streaming_handler.py b/litellm/litellm_core_utils/streaming_handler.py index e402023d240..5442567b1a5 100644 --- a/litellm/litellm_core_utils/streaming_handler.py +++ b/litellm/litellm_core_utils/streaming_handler.py @@ -1134,7 +1134,11 @@ class CustomStreamWrapper: ): if self.received_finish_reason is not None: _chunk_has_content = isinstance(chunk, dict) and ( - bool(chunk.get("text", "")) or chunk.get("tool_use") is not None + bool(chunk.get("text", "")) + or chunk.get("tool_use") is not None + # Usage-only final chunks are valid and needed to surface + # finish_reason/usage to downstream translators. + or chunk.get("usage") is not None ) if not _chunk_has_content and ( not isinstance(chunk, dict) diff --git a/litellm/responses/litellm_completion_transformation/streaming_iterator.py b/litellm/responses/litellm_completion_transformation/streaming_iterator.py index 8e75ffdff61..767281d43ab 100644 --- a/litellm/responses/litellm_completion_transformation/streaming_iterator.py +++ b/litellm/responses/litellm_completion_transformation/streaming_iterator.py @@ -1016,14 +1016,18 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator): ): if src and isinstance(src, dict): self._merge_provider_specific_fields(src) - # Emit any just-queued output_item event - if self._pending_response_events: - return self._pending_response_events.pop(0) + # Always snapshot before returning any pending events so that + # finish_reason (e.g. content_filter) is captured even when + # _ensure_output_item_for_chunk queues events on the same chunk. + # This mirrors the async path (see __anext__). self.collected_chat_completion_chunks.append( self._snapshot_chunk_for_stream_chunk_builder( cast(ModelResponseStream, chunk) ) ) + # Emit any just-queued output_item event + if self._pending_response_events: + return self._pending_response_events.pop(0) response_api_chunk = ( self._transform_chat_completion_chunk_to_response_api_chunk( chunk diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index 2207acbb37a..8449620c693 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -1519,7 +1519,7 @@ class LiteLLMCompletionResponsesConfig: """ Map chat completion finish_reason to responses API status. - Chat completion finish_reason values include: "stop", "length", "tool_calls", "content_filter", "function_call" + Chat completion finish_reason values include: "stop", "length", "tool_calls", "content_filter", "function_call", "refusal" Responses API status values are: "completed", "failed", "in_progress", "cancelled", "queued", "incomplete" Args: @@ -1534,7 +1534,7 @@ class LiteLLMCompletionResponsesConfig: # Map finish reasons to status if finish_reason in ["stop", "tool_calls", "function_call"]: return "completed" - elif finish_reason in ["length", "content_filter"]: + elif finish_reason in ["length", "content_filter", "refusal"]: return "incomplete" else: # Default to completed for unknown finish reasons diff --git a/tests/test_litellm/litellm_core_utils/test_streaming_handler.py b/tests/test_litellm/litellm_core_utils/test_streaming_handler.py index aad3de306c7..904493e02d2 100644 --- a/tests/test_litellm/litellm_core_utils/test_streaming_handler.py +++ b/tests/test_litellm/litellm_core_utils/test_streaming_handler.py @@ -770,7 +770,9 @@ async def test_vertex_streaming_bad_request_not_midstream(logging_obj: Logging): from litellm.llms.vertex_ai.common_utils import VertexAIError async def _raise_bad_request(**kwargs): - raise VertexAIError(status_code=400, message="invalid maxOutputTokens", headers=None) + raise VertexAIError( + status_code=400, message="invalid maxOutputTokens", headers=None + ) response = CustomStreamWrapper( completion_stream=None, @@ -788,7 +790,9 @@ async def test_vertex_streaming_bad_request_not_midstream(logging_obj: Logging): @pytest.mark.asyncio -async def test_vertex_streaming_rate_limit_triggers_midstream_fallback(logging_obj: Logging): +async def test_vertex_streaming_rate_limit_triggers_midstream_fallback( + logging_obj: Logging, +): """Ensure Vertex 429 rate-limit errors raise MidStreamFallbackError, not RateLimitError. Regression test for https://github.com/BerriAI/litellm/issues/20870 @@ -797,7 +801,9 @@ async def test_vertex_streaming_rate_limit_triggers_midstream_fallback(logging_o from litellm.llms.vertex_ai.common_utils import VertexAIError async def _raise_rate_limit(**kwargs): - raise VertexAIError(status_code=429, message="Resource exhausted.", headers=None) + raise VertexAIError( + status_code=429, message="Resource exhausted.", headers=None + ) response = CustomStreamWrapper( completion_stream=None, @@ -825,7 +831,9 @@ def test_sync_streaming_rate_limit_triggers_midstream_fallback(logging_obj: Logg from litellm.llms.vertex_ai.common_utils import VertexAIError def _raise_rate_limit(**kwargs): - raise VertexAIError(status_code=429, message="Resource exhausted.", headers=None) + raise VertexAIError( + status_code=429, message="Resource exhausted.", headers=None + ) response = CustomStreamWrapper( completion_stream=None, @@ -850,7 +858,9 @@ def test_sync_streaming_bad_request_not_midstream(logging_obj: Logging): from litellm.llms.vertex_ai.common_utils import VertexAIError def _raise_bad_request(**kwargs): - raise VertexAIError(status_code=400, message="invalid maxOutputTokens", headers=None) + raise VertexAIError( + status_code=400, message="invalid maxOutputTokens", headers=None + ) response = CustomStreamWrapper( completion_stream=None, @@ -1363,6 +1373,7 @@ def _build_chunks(pattern: list[str], N: int) -> list[ModelResponseStream]: chunks.append(_make_chunk(p)) return chunks + _REPETITION_TEST_CASES = [ # Basic cases pytest.param( @@ -1419,7 +1430,14 @@ _REPETITION_TEST_CASES = [ id="last_chunk_different_no_raise", ), pytest.param( - ["same"] * (litellm.REPEATED_STREAMING_CHUNK_LIMIT // 2 + 1) + ["different_mid"] + ["same"] * (litellm.REPEATED_STREAMING_CHUNK_LIMIT - litellm.REPEATED_STREAMING_CHUNK_LIMIT // 2 + 1), + ["same"] * (litellm.REPEATED_STREAMING_CHUNK_LIMIT // 2 + 1) + + ["different_mid"] + + ["same"] + * ( + litellm.REPEATED_STREAMING_CHUNK_LIMIT + - litellm.REPEATED_STREAMING_CHUNK_LIMIT // 2 + + 1 + ), False, id="middle_chunk_different_no_raise", ), @@ -1429,7 +1447,9 @@ _REPETITION_TEST_CASES = [ id="last_two_different_no_raise", ), pytest.param( - ["diff"] * litellm.REPEATED_STREAMING_CHUNK_LIMIT + ["same"] * litellm.REPEATED_STREAMING_CHUNK_LIMIT + ["diff"], + ["diff"] * litellm.REPEATED_STREAMING_CHUNK_LIMIT + + ["same"] * litellm.REPEATED_STREAMING_CHUNK_LIMIT + + ["diff"], True, id="in_between_same_and_diff_raise", ), @@ -1455,6 +1475,8 @@ def test_raise_on_model_repetition( for chunk in chunks: wrapper.chunks.append(chunk) wrapper.raise_on_model_repetition() + + def test_usage_chunk_after_finish_reason_updates_hidden_params(logging_obj): """ Test that provider-reported usage from a post-finish_reason chunk @@ -1536,12 +1558,13 @@ def test_usage_chunk_after_finish_reason_updates_hidden_params(logging_obj): last_chunk = collected[-1] hidden_usage = last_chunk._hidden_params.get("usage") assert hidden_usage is not None, "Expected usage in _hidden_params" - assert hidden_usage.prompt_tokens == 20, ( - f"Expected prompt_tokens=20 from provider, got {hidden_usage.prompt_tokens}" - ) - assert hidden_usage.completion_tokens == 135, ( - f"Expected completion_tokens=135 from provider, got {hidden_usage.completion_tokens}" - ) + assert ( + hidden_usage.prompt_tokens == 20 + ), f"Expected prompt_tokens=20 from provider, got {hidden_usage.prompt_tokens}" + assert ( + hidden_usage.completion_tokens == 135 + ), f"Expected completion_tokens=135 from provider, got {hidden_usage.completion_tokens}" + @pytest.mark.asyncio async def test_custom_stream_wrapper_aclose(): @@ -1615,9 +1638,9 @@ def test_content_not_dropped_when_finish_reason_already_set( result = initialized_custom_stream_wrapper.chunk_creator(chunk=content_chunk) - assert result is not None, ( - "chunk_creator() returned None — content was dropped (issue #22098)" - ) + assert ( + result is not None + ), "chunk_creator() returned None — content was dropped (issue #22098)" assert result.choices[0].delta.content == "world!" @@ -1669,18 +1692,45 @@ def test_tool_use_not_dropped_when_finish_reason_already_set( result = initialized_custom_stream_wrapper.chunk_creator(chunk=tool_chunk) - assert result is not None, ( - "chunk_creator() returned None — tool_use data was dropped" - ) + assert ( + result is not None + ), "chunk_creator() returned None — tool_use data was dropped" tool_calls = result.choices[0].delta.tool_calls - assert tool_calls is not None and len(tool_calls) > 0, ( - "tool_calls should contain at least one tool call" - ) + assert ( + tool_calls is not None and len(tool_calls) > 0 + ), "tool_calls should contain at least one tool call" assert tool_calls[0].id == "call_1" assert tool_calls[0].function.name == "get_weather" +def test_usage_only_chunk_not_dropped_when_finish_reason_already_set( + initialized_custom_stream_wrapper: CustomStreamWrapper, +): + """ + Regression test: usage-only chunks must not be dropped once finish_reason + is already set. Dropping these chunks can lose terminal finish_reason in + downstream Responses API streaming translation. + """ + initialized_custom_stream_wrapper.received_finish_reason = "content_filter" + initialized_custom_stream_wrapper.custom_llm_provider = "anthropic" + + usage_only_chunk = { + "text": "", + "tool_use": None, + "is_finished": False, + "finish_reason": "", + "usage": {"prompt_tokens": 10, "completion_tokens": 1, "total_tokens": 11}, + "index": 0, + } + + result = initialized_custom_stream_wrapper.chunk_creator(chunk=usage_only_chunk) + + assert result is not None, "usage-only chunk should not be dropped" + assert result.choices[0].finish_reason == "content_filter" + assert result.usage is not None + + @pytest.mark.asyncio async def test_custom_stream_wrapper_anext_does_not_block_event_loop_for_sync_iterators( logging_obj: Logging, diff --git a/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py b/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py index 4e44ef9e50c..f53e0391be0 100644 --- a/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py +++ b/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py @@ -504,6 +504,35 @@ class TestLiteLLMCompletionResponsesConfig: ] assert item.status != "stop" + def test_transform_chat_completion_response_status_with_refusal(self): + """ + `finish_reason=refusal` should map to `status=incomplete` in Responses API. + """ + chat_completion_response = ModelResponse( + id="test-response-id", + created=1234567890, + model="claude-sonnet-4-5", + object="chat.completion", + choices=[ + Choices( + finish_reason="refusal", + index=0, + message=Message( + content="", + role="assistant", + ), + ) + ], + ) + + responses_api_response = LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response( + request_input="this is a test", + responses_api_request={}, + chat_completion_response=chat_completion_response, + ) + + assert responses_api_response.status == "incomplete" + def test_transform_chat_completion_response_preserves_hidden_params(self): """Test that _hidden_params from chat completion response are preserved in responses API response""" # Setup @@ -976,10 +1005,11 @@ class TestToolTransformation: tools = [vertex_tool] # Execute - result_tools, web_search_options = ( - LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( - tools=tools - ) + ( + result_tools, + web_search_options, + ) = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( + tools=tools ) # Assert @@ -999,10 +1029,11 @@ class TestToolTransformation: tools = [mcp_tool] # Execute - result_tools, web_search_options = ( - LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( - tools=tools - ) + ( + result_tools, + web_search_options, + ) = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( + tools=tools ) # Assert @@ -1022,10 +1053,11 @@ class TestToolTransformation: tools = [computer_use_tool] # Execute - result_tools, web_search_options = ( - LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( - tools=tools - ) + ( + result_tools, + web_search_options, + ) = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( + tools=tools ) # Assert @@ -1045,10 +1077,11 @@ class TestToolTransformation: tools = [web_search_tool] # Execute - result_tools, web_search_options = ( - LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( - tools=tools - ) + ( + result_tools, + web_search_options, + ) = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( + tools=tools ) # Assert @@ -1077,10 +1110,11 @@ class TestToolTransformation: tools = [function_tool] # Execute - result_tools, web_search_options = ( - LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( - tools=tools - ) + ( + result_tools, + web_search_options, + ) = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( + tools=tools ) # Assert @@ -1108,10 +1142,11 @@ class TestToolTransformation: tools = [function_tool] # Execute - result_tools, _ = ( - LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( - tools=tools - ) + ( + result_tools, + _, + ) = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( + tools=tools ) # Assert @@ -1135,10 +1170,11 @@ class TestToolTransformation: tools = [function_tool] # Execute - result_tools, _ = ( - LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( - tools=tools - ) + ( + result_tools, + _, + ) = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( + tools=tools ) # Assert @@ -1162,10 +1198,11 @@ class TestToolTransformation: tools = [code_execution_tool] # Execute - result_tools, _ = ( - LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( - tools=tools - ) + ( + result_tools, + _, + ) = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( + tools=tools ) # Assert @@ -1187,10 +1224,11 @@ class TestToolTransformation: tools = [tool_search_regex, tool_search_bm25] # Execute - result_tools, _ = ( - LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( - tools=tools - ) + ( + result_tools, + _, + ) = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( + tools=tools ) # Assert @@ -1220,10 +1258,11 @@ class TestToolTransformation: ] # Execute - result_tools, web_search_options = ( - LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( - tools=tools - ) + ( + result_tools, + web_search_options, + ) = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( + tools=tools ) # Assert @@ -1256,10 +1295,11 @@ class TestToolTransformation: tools = [function_tool] # Execute - result_tools, _ = ( - LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( - tools=tools - ) + ( + result_tools, + _, + ) = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( + tools=tools ) # Assert @@ -1280,10 +1320,11 @@ class TestToolTransformation: tools = [function_tool] # Execute - result_tools, _ = ( - LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( - tools=tools - ) + ( + result_tools, + _, + ) = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( + tools=tools ) # Assert @@ -1302,10 +1343,11 @@ class TestToolTransformation: tools = [function_tool] # Execute - result_tools, _ = ( - LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( - tools=tools - ) + ( + result_tools, + _, + ) = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( + tools=tools ) # Assert @@ -1325,10 +1367,11 @@ class TestToolTransformation: tools = [function_tool] # Execute - result_tools, _ = ( - LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( - tools=tools - ) + ( + result_tools, + _, + ) = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( + tools=tools ) # Assert @@ -2055,6 +2098,53 @@ class TestEnsureOutputItemContentPartAdded: assert events[1].part.type == "output_text" assert iterator.sent_content_part_added_event is True + def test_emit_response_completed_uses_stream_finish_reason(self): + """ + When the assembled model response carries finish_reason="content_filter" + (snapshotted from the underlying stream before any pending events fire), + _emit_response_completed_event must produce status="incomplete". + """ + from unittest.mock import Mock + + import litellm + from litellm.responses.litellm_completion_transformation.streaming_iterator import ( + LiteLLMCompletionStreamingIterator, + ) + + mock_stream_wrapper = Mock(spec=litellm.CustomStreamWrapper) + mock_stream_wrapper.logging_obj = Mock() + + iterator = LiteLLMCompletionStreamingIterator( + model="anthropic/claude-sonnet-4-6", + litellm_custom_stream_wrapper=mock_stream_wrapper, + request_input="test", + responses_api_request={}, + custom_llm_provider="anthropic", + ) + + litellm_model_response = ModelResponse( + id="chatcmpl-test", + created=1234567890, + model="anthropic/claude-sonnet-4-6", + object="chat.completion", + choices=[ + Choices( + finish_reason="content_filter", + index=0, + message=Message(content="", role="assistant"), + ) + ], + usage=Usage(prompt_tokens=10, completion_tokens=1, total_tokens=11), + ) + + completed_event = iterator._emit_response_completed_event( + litellm_model_response + ) + + assert completed_event is not None + assert completed_event.response.status == "incomplete" + assert completed_event.response.output[0].status == "incomplete" + def test_reasoning_item_does_not_emit_content_part_added(self): """Reasoning items should not get a content_part.added event.""" from litellm.types.llms.openai import OutputItemAddedEvent From c13be44e44decfe022640c112c3b79259051906e Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Sat, 11 Apr 2026 21:23:24 +0530 Subject: [PATCH 2/9] feat(guardrails): optional skip system message in unified guardrail inputs (#25481) * feat(guardrails): optional skip system message in unified guardrail inputs Made-with: Cursor * feat(dashboard): skip_system_message_in_guardrail in guardrail UI Add a tri-state control (inherit / yes / no) when creating or editing guardrails so admins can set litellm_params.skip_system_message_in_guardrail without YAML. Table edit merges existing litellm_params before PUT to avoid wiping content-filter and other provider fields. Document the dashboard flow in the guardrails quick start with a screenshot. Made-with: Cursor * fix(guardrails): type structured_messages as AllMessageValues for mypy Use AllMessageValues in openai_messages_without_system and cast adapter request messages so GenericGuardrailAPIInputs matches TypedDict. Made-with: Cursor --- docs/my-website/docs/proxy/config_settings.md | 1 + .../docs/proxy/guardrails/quick_start.md | 117 ++++++++++-------- .../img/skip_system_message_guardrail_ui.png | Bin 0 -> 129434 bytes litellm/__init__.py | 1 + .../chat/guardrail_translation/handler.py | 19 ++- .../base_llm/guardrail_translation/utils.py | 24 ++++ .../chat/guardrail_translation/handler.py | 22 +++- .../proxy/guardrails/guardrail_registry.py | 7 ++ litellm/types/guardrails.py | 10 ++ .../test_unified_guardrail.py | 111 +++++++++++++++++ .../guardrails/add_guardrail_form.tsx | 20 +++ .../guardrails/edit_guardrail_form.tsx | 89 ++++++++----- .../components/guardrails/guardrail_info.tsx | 49 +++++++- .../guardrail_info_helpers.test.tsx | 16 +++ .../guardrails/guardrail_info_helpers.tsx | 16 +++ .../components/guardrails/guardrail_table.tsx | 6 +- 16 files changed, 419 insertions(+), 89 deletions(-) create mode 100644 docs/my-website/img/skip_system_message_guardrail_ui.png create mode 100644 litellm/llms/base_llm/guardrail_translation/utils.py diff --git a/docs/my-website/docs/proxy/config_settings.md b/docs/my-website/docs/proxy/config_settings.md index c64d475fdaa..db38cf5426b 100644 --- a/docs/my-website/docs/proxy/config_settings.md +++ b/docs/my-website/docs/proxy/config_settings.md @@ -197,6 +197,7 @@ router_settings: | key_generation_settings | object | Restricts who can generate keys. [Further docs](./virtual_keys.md#restricting-key-generation) | | disable_add_transform_inline_image_block | boolean | For Fireworks AI models - if true, turns off the auto-add of `#transform=inline` to the url of the image_url, if the model is not a vision model. | | use_chat_completions_url_for_anthropic_messages | boolean | If true, routes OpenAI `/v1/messages` requests through chat/completions instead of the Responses API. Can also be set via env var `LITELLM_USE_CHAT_COMPLETIONS_URL_FOR_ANTHROPIC_MESSAGES=true`. | +| skip_system_message_in_guardrail | boolean | If true, unified guardrails omit `role: system` from scanned input on **chat completions** and **Anthropic `/v1/messages`** only; the LLM still receives full messages. Per-guardrail override: `litellm_params.skip_system_message_in_guardrail` on each guardrail. [Guardrails quick start](./guardrails/quick_start#skip-system-messages-in-guardrail-evaluation) | | disable_hf_tokenizer_download | boolean | If true, it defaults to using the openai tokenizer for all models (including huggingface models). | | enable_json_schema_validation | boolean | If true, enables json schema validation for all requests. | | enable_key_alias_format_validation | boolean | If true, validates `key_alias` format on `/key/generate` and `/key/update`. Must be 2-255 chars, start/end with alphanumeric, only allow `a-zA-Z0-9_-/.@`. Default `false`. | diff --git a/docs/my-website/docs/proxy/guardrails/quick_start.md b/docs/my-website/docs/proxy/guardrails/quick_start.md index 5abe499e30b..ed9d2ca128b 100644 --- a/docs/my-website/docs/proxy/guardrails/quick_start.md +++ b/docs/my-website/docs/proxy/guardrails/quick_start.md @@ -9,6 +9,7 @@ Setup Prompt Injection Detection, PII Masking on LiteLLM Proxy (AI Gateway) ## 1. Define guardrails on your LiteLLM config.yaml Set your guardrails under the `guardrails` section + ```yaml model_list: - model_name: gpt-3.5-turbo @@ -82,27 +83,58 @@ For generic guardrail APIs you can also set **static headers** (`headers`: key/v - `during_call` Run **during** LLM call, on **input** Same as `pre_call` but runs in parallel as LLM call. Response not returned until guardrail check completes - A list of the above values to run multiple modes, e.g. `mode: [pre_call, post_call]` +### Skip system messages in guardrail evaluation + +You can stop **unified** guardrails from scanning `role: system` content while still sending the full `messages` list to the model. + +**Global** — in `litellm_settings`: + +```yaml +litellm_settings: + skip_system_message_in_guardrail: true +``` + +**Per guardrail** — under that guardrail’s `litellm_params`: set `skip_system_message_in_guardrail: true` or `false`. If omitted, the global `litellm_settings` value is used; per-guardrail `false` forces system messages to be included even when the global flag is `true`. + +**Via LiteLLM UI** — when **creating** or **editing** a guardrail in the LiteLLM Admin Dashboard, set **Skip system messages in guardrail** (under Basic Info on create, or in the edit / guardrail settings flows): + + +| UI option | Effect | +| ------------------------------------- | -------------------------------------------------------------------------------------- | +| **Use global default** | Uses `litellm_settings.skip_system_message_in_guardrail` from your proxy config | +| **Yes — exclude from guardrail scan** | Sets per-guardrail `skip_system_message_in_guardrail: true` | +| **No — always include in scan** | Sets per-guardrail `skip_system_message_in_guardrail: false` (overrides a global skip) | + + +Create guardrail: Skip system messages in guardrail dropdown with Use global default, Yes exclude from guardrail scan, and No always include in scan + +**Where this applies:** Only the **unified** guardrail path (providers that implement `apply_guardrail` and run through LiteLLM’s message translation layer) on **OpenAI Chat Completions** (`/v1/chat/completions`) and **Anthropic Messages** (`/v1/messages`). Examples include Presidio, Bedrock guardrails, `litellm_content_filter`, OpenAI Moderation, Generic Guardrail API, and custom code guardrails that define `apply_guardrail`. + +**Where this does *not* apply:** Guardrails that run only via direct hooks on the raw request (e.g. Lakera v2, Aporia, DynamoAI, Javelin, Lasso, Pangea, Model Armor, Azure Content Safety hooks, Guardrails AI, AIM, tool permission, MCP security). It also does not apply to other routes until those endpoints use the same translation layer (e.g. Responses API, embeddings, speech). + ### Load Balancing Guardrails Need to distribute guardrail requests across multiple accounts or regions? See [Guardrail Load Balancing](./guardrail_load_balancing.md) for details on: + - Load balancing across multiple AWS Bedrock accounts (useful for rate limit management) - Weighted distribution across guardrail instances - Multi-region guardrail deployments - -## 2. Start LiteLLM Gateway - +## 2. Start LiteLLM Gateway ```shell litellm --config config.yaml --detailed_debug ``` -## 3. Test request +## 3. Test request **[Langchain, OpenAI SDK Usage Examples](../proxy/user_keys#request-format)** - - + Expect this to fail since since `ishaan@berri.ai` in the request is PII @@ -141,9 +173,9 @@ Expected response on failure ``` - - + + ```shell curl -i http://localhost:4000/v1/chat/completions \ @@ -158,10 +190,8 @@ curl -i http://localhost:4000/v1/chat/completions \ }' ``` - - ## **Default On Guardrails** @@ -183,7 +213,6 @@ guardrails: In this request, the guardrail `aporia-pre-guard` will run on every request because `default_on: true` is set. - ```shell curl -i http://localhost:4000/v1/chat/completions \ -H "Content-Type: application/json" \ @@ -207,6 +236,7 @@ x-litellm-applied-guardrails: aporia-pre-guard ### Guardrail Policies Need more control? Use [Guardrail Policies](./guardrail_policies.md) to: + - Group guardrails into reusable policies - Enable/disable guardrails for specific teams, keys, or models - Inherit from existing policies and override specific guardrails @@ -217,7 +247,6 @@ Need more control? Use [Guardrail Policies](./guardrail_policies.md) to: Pass `guardrails` to your request body to test it - ```shell curl -i http://localhost:4000/v1/chat/completions \ -H "Content-Type: application/json" \ @@ -239,7 +268,6 @@ Follow this simple workflow to implement and tune guardrails: First, check what guardrails are available and their parameters: - Call `/guardrails/list` to view available guardrails and the guardrail info (supported parameters, description, etc) ```shell @@ -271,9 +299,12 @@ Expected response } ``` -> + + This config will return the `/guardrails/list` response above. The `guardrail_info` field is optional and you can add any fields under info for consumers of your guardrail -> + + + ```yaml - guardrail_name: "aporia-post-guard" litellm_params: @@ -291,9 +322,10 @@ This config will return the `/guardrails/list` response above. The `guardrail_in type: "boolean" ``` - ### 2. Apply Guardrails + Add selected guardrails to your chat completion request: + ```shell curl -i http://localhost:4000/v1/chat/completions \ -H "Content-Type: application/json" \ @@ -322,7 +354,6 @@ curl -i http://localhost:4000/v1/chat/completions \ }' ``` - ### 4. ✨ Pass Dynamic Parameters to Guardrail :::info @@ -334,9 +365,8 @@ curl -i http://localhost:4000/v1/chat/completions \ Use this to pass additional parameters to the guardrail API call. e.g. things like success threshold. **[See `guardrails` spec for more details](#spec-guardrails-parameter)** - - + Set `guardrails={"aporia-pre-guard": {"extra_body": {"success_threshold": 0.9}}}` to pass additional parameters to the guardrail @@ -371,10 +401,10 @@ response = client.chat.completions.create( print(response) ``` - - + + ```shell curl --location 'http://0.0.0.0:4000/chat/completions' \ @@ -396,11 +426,8 @@ curl --location 'http://0.0.0.0:4000/chat/completions' \ } }' ``` - - - @@ -426,9 +453,6 @@ Monitor which guardrails were executed and whether they passed or failed. e.g. g - - - ### ✨ Control Guardrails per API Key :::info @@ -438,12 +462,12 @@ Monitor which guardrails were executed and whether they passed or failed. e.g. g ::: Use this to control what guardrails run per API Key. In this tutorial we only want the following guardrails to run for 1 API Key + - `guardrails`: ["aporia-pre-guard", "aporia-post-guard"] **Step 1** Create Key with guardrail settings - - + ```shell curl -X POST 'http://0.0.0.0:4000/key/generate' \ @@ -454,8 +478,7 @@ curl -X POST 'http://0.0.0.0:4000/key/generate' \ }' ``` - - + ```shell curl --location 'http://0.0.0.0:4000/key/update' \ @@ -467,8 +490,7 @@ curl --location 'http://0.0.0.0:4000/key/update' \ }' ``` - - + **Step 2** Test it with new key @@ -499,8 +521,7 @@ Run guardrails based on the user-agent header. This is useful for running pre-ca Both `default` and tag values can be a single mode string or a list of modes. - - + ```yaml model_list: @@ -522,11 +543,10 @@ guardrails: default_on: true # run on every request ``` - - + ```yaml -model_list: +Per guardrailmodel_list: - model_name: gpt-3.5-turbo litellm_params: model: gpt-3.5-turbo @@ -545,8 +565,7 @@ guardrails: default_on: true ``` - - + ```yaml model_list: @@ -568,8 +587,6 @@ guardrails: default_on: true ``` - - ### ✨ Model-level Guardrails @@ -580,10 +597,8 @@ guardrails: ::: - This is great for cases when you have an on-prem and hosted model, and just want to run prevent sending PII to the hosted model. - ```yaml model_list: - model_name: claude-sonnet-4 @@ -620,8 +635,7 @@ guardrails: ::: - -#### 1. Disable team from modifying guardrails +#### 1. Disable team from modifying guardrails ```bash curl -X POST 'http://0.0.0.0:4000/team/update' \ @@ -633,7 +647,7 @@ curl -X POST 'http://0.0.0.0:4000/team/update' \ }' ``` -#### 2. Try to disable guardrails for a call +#### 2. Try to disable guardrails for a call ```bash curl --location 'http://0.0.0.0:4000/chat/completions' \ @@ -672,8 +686,7 @@ Expect to NOT see `+1 412-612-9992` in your server logs on your callback. The `pii_masking` guardrail ran on this request because api key=sk-jNm1Zar7XfNdZXp49Z1kSQ has `"permissions": {"pii_masking": true}` ::: - -## Specification +## Specification ### `guardrails` Configuration on YAML @@ -723,6 +736,7 @@ The `guardrails` parameter can be passed to any LiteLLM Proxy endpoint (`/chat/c #### Format Options 1. Simple List Format: + ```python "guardrails": [ "aporia-pre-guard", @@ -730,9 +744,10 @@ The `guardrails` parameter can be passed to any LiteLLM Proxy endpoint (`/chat/c ] ``` -2. Advanced Dictionary Format: +1. Advanced Dictionary Format: In this format the dictionary key is `guardrail_name` you want to run + ```python "guardrails": { "aporia-pre-guard": { @@ -745,6 +760,7 @@ In this format the dictionary key is `guardrail_name` you want to run ``` #### Type Definition + ```python guardrails: Union[ List[str], # Simple list of guardrail names @@ -754,3 +770,4 @@ guardrails: Union[ class DynamicGuardrailParams: extra_body: Dict[str, Any] # Additional parameters for the guardrail ``` + diff --git a/docs/my-website/img/skip_system_message_guardrail_ui.png b/docs/my-website/img/skip_system_message_guardrail_ui.png new file mode 100644 index 0000000000000000000000000000000000000000..466ac7daa6e12ec5adba0cb87cb008529e6aadf6 GIT binary patch literal 129434 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
2glaF@vQjL%_Rg&0BK*rQ2Mq}G*2 z;lxtL6Ed2CMeuxnIlp>t{zGlAkxFq`zmbe5VQE@^T|F@eblP`m*HIJ<-+@QyCH9Ew zeozvprIM33qrQ$UFivSVaFI;z49I!(n}H{O1gVvuV+wpr%eyhY#1jmP)8UwQ{ijv% zwj{iH;;e|uaVvM8tYpeuDWP%_+4;hAPO9vD>#f?+Ifx^Ni z_;=P_R&19z(o-FK^@MQ+Myemb)g}ko*2aS0n$XJ500fQ^`Z@hTHlo9647E0D-uvQ|N}E1+z=YWd*IboKfYL(g*kb*1 zXRvZhw1fLO4xYB`^pRZt#lFk|H%nc+pKnDF@Qzzf= z(G7~~9bNOny}{l)6I2P*x*duUMfz6wl@GfW*kVsrcb;IYFyYHSP6(eG&)?*(x$31I zIE+o6s!74ra*7D5wjP{Q#R#?eA~#=79@;p4cqHBEk3ML~HCId>1}kNMm9Tr@WRF9B|OC&oP9r7$eD!p`HH6$@NP$r=%L!QE2>))K)u4 zm=t(QsCJcGFIM?R?PQF~q2Y5at_tUzmK8ELrE{FZLe_bnjBDo}QEX|i(VMu|zLeJK zT3~zTZZx57v3_HE5m20h+0D0{U%VA+;dx;IC+~GjM(ym1+Hnr9er|LpbtmvVT4^v0 zDNN4jRcms_BFm!gZI+`v!?VAxqR-5cQ2EQTV}2NwNx)aDWWxW= ztiRvp4f{ue$H%B4X=`RuHWj6{w-Ae8yTE`mbNdFLK-4o*OM8h$iR+nbbUUYHc`3E? z%5Kl&pBMvhN{T}^mp~ASHcf47E3Z#RSYtHM{u~db3q<%+2**4-+~&&{K0T7c>1bDu zz34o+Jclun?{G?8V~pG#40jVPZJvt+jlGRL4GskXL|+$klaI~ma%qFGu) z!i(Iy5_z_i6yAs_O#{-7`@Kl@;$1pgHL8<&RUL*2{r(O3bJ=9h%$$;W?P+u{8AhJ^ zBZn7GmXK;A$WN&a42s)*eHgrAeJ9h|-YL^VW+1czSMFWC7HQ-|@Rs2JEd5DjzsbReh7YNJi}RM?K7)OU^77;ab-+q!Jmit)=o(#aY|!RiGkcXPvY5e0I3h7m zaLr#*;3o?ywt4>xwWr5k*H@4%?0dN_6lX_&!Cse zWsc*=OUo*`<>A{#j?jRVzz(^AIqy00TFdm~`Eq1tE7~&iW8XPfLZiZ1ebyLa)GGIk zY>r*U!jjTr#{hs)%@5H~se4Y3toG!^C0K60sf72AMC$P_42*4lq$Zr%{_8ydVDznkwF()+<>N9we{ zeJZ3Jnja3(*nzdXzQ%d=zV*{;&;J{aRid?txILKmvBvXcat1b1a!FtX4-Y@nc<4(>{ANLhw#x8;zTvVhitFSwDuaKF7y)} zs0o%~{gi+={_CFd4i}frcNNOwGj^X&e!&DJ^ z9G`|N9c0UwIPeMJEAt<`QANSXZ?oB@_vxP70bPuLw*jn z<|GeL8|_bbW~2Xs|0aWcRxw_LV zZ#PH(t~-O)6wOYjJ~3J@5Nm@BqNCQhRYZDZ-oBl<`YD$`C+vU*Q$l0-oqp9X{i*8O109zzWcdncQrG0{6>6Q{I7Xj z_|Kf11%iuqNwRh(LCU@_@E%qG=&}0pRX0~;%r}SW2L7ulxv^vk$KE$KXLzgIPyAq= z|Lsj9@{i_S%H0j6!u@)0yuns_y0hiN^$Y09c30Ozx3@MY+50o9umq9ZNTVRBsjS@> zdvUwZnnAQR&Z!--u3E6c(|4vE{~YG|QxU37q9qpz8y5pI+Z)>sMFL@689{KzV zRN_l(Z1}B*vCo2kV)^H-0T+v>c)7UxpQHWH7ZF+e_oYV}6=?nBE5JNASB`z@+O$yG zxu2eb!wleuWF-{D{k`Kma2l`@w%l&z@nHQO|MmBM^hYf~j%Hqn{SfW{{>9Nr;1iXd z)nhxGkbfIglqBEHlgjzO{bcjN*JKNVPyFwe{Od{m@0R@I)Bb+(zgzP6PW)8M07YQLwsLSb$;{MEecXhcxn5M;d%g5hFlPMGp*L3nXDS zhcM;}qkd49;LD&OGo`YZMbo9SD6b0uY!&tB2W7rh!+M7|ZtUB81q*;u^Qd&@YHZFj z2Ej=1`N49dGpmc^_Fy&z(ws_Q0%efK>Tux1NayFJ#qG_e5kq4L}s&eGCMWrZAs(zmve05#o-qJil*Xb*sv*4je;DZv?lrg9 zFqL^5{1340Wk0^HpZEB3$c50OY$u?nzn0{mpZ(8ZRES}MLP%grj5x; zdJ= zwZ$zMJqwVJn;q~eGM{DvgQqZTHaRTcy29Czo>W~AZ_XUf_}2*ak15@=0OadSw8fcw zkO|GL3QDI+0TmE7g8(lmWO3Yfe!QWM(huqhUJ}@dZTy&e#4GR3es5A%D{cFRCV7p;AYvriuO~X5au@Oce9~lxUtlfw{0a8xo2mOVy5C{R+!Zp18 zTSd}TJw!?Z1TX6Kvp7@p76WZS|M`Rp-*>YF{dG2W+|FnT=6tlnka^r_0E(Y2hDvAWm#tqbls>|&7Mq0QG9;Q{gR!>nQrMP5TZDxnB z#1^mw0XP%B+xV~z^tcW@|o!f&OhZZm9sPk{-_|VWnDZ)HU|@w zX*RS7oQOxm-1gS^wu)DBbI+3h(@A$lS!z_XD)>T3(ZFPnLYW(RI9No|+o zWN5uH(vM-onK#7|5G7hs1$8YF!7*<&~$lNh7;>3%Kmy0D|sGV|_X^=o^b zxu<>~021H=>(YrFjmJO%5e|D-f@W6)k->O0c}4h@S*JdPvHINsFKcWs2M^T;Efc)x zA(?`SODtiA7>zLVJow$=ZM{DdkNO2^UxReoyskU7xVab}&fB$lv)9M_g7N57z2Gy( z)>@NBf4^t?`7-o_PWVvgPFfPn!37^Z|2qGKAo+;vK7nVtdYaJJY453`>reB?K-VMR z7*%knj8k_cj5+T_j3Vqy4VVII_+pGbT6DSb>tk=TlAsQcf@le`kCzxeZw@M$fskNH zTZ@j;o9HXPfzs9&WY_z!^iC?QY-~f^ejUOqFDTHu@A*>g7K27RFPvqRWEd!#WuIB? z-+rMbS|yY%l|_!jw+38*FuCI4eWnER*eYK6*`pxF#+mKO@Sp@B-UxX%j+>^EFDLHY?RQqL0c1VgeBoSRmyw#p zhl=~s<R4N^hwQ0@68t_(whp73a3Hs@&Y^{242YC8H3KR~>5XX8F3Z zzh~AK??w5ssnl}R%X*ttpDW_mbVzSzkqpDV6>4XJ$34B@;{Ckeg#PP0OA=ZA{#+>I znVT$PCnCx1W5B|Q75wehF@0bX2a?J+5=%niR%)wu+N~QOVmEaCfzbW&IjJ*Cw%GBl z`Eg16!@=9EfGy^z?V&dlZwbLhwV{zgYMU#)PDEFCe6|1f#U{#-2}iZ5bc4ONC}OZQ zYb$ehb15{tKfZbDEtjz;%r=&j@Oq$Wi*Bw&>3Dn{?II@FB_{aFNc(w}QQ=zC_S!a1 zLGN~7@!L5TuA{J?k74BBm1of)3&S^Jw|(duTP@pvR4>G+EaWx0C^wJR1;+mM0G_>a zL69Hw|JH8c0`0pGtKRGy2AaypYC8Ev62t}3abad77;r&l`RmQ-9BJkJ!Y7|0*vqUEPkFeo7Ph!{DAh=cj01xA zQAzr8S^0C-djLWdlIANMI(Btq{5b@HReZWTVGxs4l8w6b}49%R0le< z%#|n7)I6A*q*NyNgH@2BMmJ%;u{WjF^>@iOjN`owi%ZZ6TY`;#hq?QWxzJn`Q zNnf$7F<*9IOE_XsY3oL~a1U+t1A(H-0I>pJodFJ-(hXo_`lx-U~!YFvAtk zyz==QXpg|$;&`BOJFP*qGM=V%tTx+1q z#V1ZFL7aH->v5$LL`Oo^lS${dwjO$2L)M(0@ZhF_`e`{5%T?iVt3OI>BdkP9omyl_ zAlMwL3>}MD+r8ZxGJ8Q->Rd?vr4FK1&*ECLyV9+u@_KRzt?W1;3CDYpjbGx}FJ{Qk z;TvltF=^va`{j^zTfF#UMvRA2L_VkP%@rAqWf}jtIJaR|frHN|I7hT%^yR9XEAa0! zx$^+M6rB|s25{0smTjD&#nWm>D_ww4F_1=Qr8^2BwSVm$&Lq=o!?r}Bvs3GrxYhip zQ(R)mYP&3(;Gmdr>g|G~4YlRu+7z1BJEjM?x7S&4BBlj>e$jrbE!>J1h%_gM(?L>l z<79)gUTMIqJtz8zF^u;LjVAEarbyiyqhQgJTkpiFv-_aitb)d;jy016l2I&}J!&fL zZ;!R}JXj!28Uw`}6qB%`|DznzVR}Cp2=n4l9OoR`(t7kja4M#)cDj6;(tkS8#**qj znoC|w{#5E^1GEp#7umRL;^U8=PZ663`ALC}INVD69DJosYO_a*gIZqn_y{pn9)LtoUm8DnH1h0*cB z6qRu$+(GUt`q$X-`rP1Do^!S@OU1IA8aJk-EK`im3-%rg`^1IGYuuujC@5gQV)Ql9 zPqY_9#+d~y<~qVC?%Q|0Y_G;xS`5%3*j-KFz-nF=HqZCMSU-xB$ zyu?|m^yBfIWIJo`3M8eI5mGmw#>6wzyOr&~y*^Pe(6Hd|A5!x2)5GA;=2Qb7&YM-M z*)X-@Ho#2XbxnKERN^o0Krv2tPH=Pa*z8?bo9~eIXIWig)3--WdG;WolTKPF>E>4?yqp_L0U3pqG$*D!UWZFjtVW%xE*T#yG zsIQOgi*&}`sqkl)6CLmIT3}i{(-u1@9&&4IKZXqM{`SX5*}P|)Iu;Z3l5iBgssvRG zxt0+B%=t~!LBK-NN{hY&f~ob`*O)-F#jv%y7HwttF~};5o^UNz+ii4q0Q97l^(|&pRG7zuUJys1S7226^4s0{* zJ+fD_BMmuFVYKS7LIw%66!7<2MewREVuFs>Q0!lUVyFn@EpN7Jf@Q+AT? z$W!Ns@0Z$3ElLbX-^RC@;U!@pbt^k`xWX8hfQ}9(R4EowY!U;4g@0HE9DVv7+Lp2x zTDHq9?sFUeTd_o!Yf2`<>y|QkDhOFeZ`~v(&?AiP&f3FElb23h|yp zro~`p2uqDq0HlrH6l3!stS}-x+)6VF(0~9 zs{$mc&cE5B=p-ki7op*mZ+K~aE~$e!74ls-CH1#%DrE7}iCWTi-Uz^r+n1=SBU}=u z&lVj=6ve1m?Q@;b8r<<$L=AY)rf4+#A1$lvCa^>XT=*P}R#B?5jOky&xjsE$gBmCZC zF-Qg)Yx$9v|F33Ty9uB(pHeHUz4D{x-1i@QDhWOU68b~`{PcgmaEJlg&FPs3 zq;o&teLnztVfBZ7{$AS7>q43VVuEKdSWj9e6iU87gGwp?*HiL zyAJ~&`MGjWsY8TyD)FvHR!|#mAjB{k>6|*zUFu*aP!sKejK2muxj_ zsUf8`CkmsZ!0jp9Sz(9Y_c_IorO0bDwf`}^>2Qs$W^YJ84(-L;UpF@eeD(H7Dwj6r(@+`2a{7kx=w#a!)1@3AxYqDm z{6yt?>BNVI!I1>>=|-^91&ouL=h4B2kie>2Uq2<~{6H}B-Mf|U@s*NayOCHU1#WpJ z<=SBzc9!731GCuU?fCV_pu@lXP~8G@`xNc$4{eRLr4A6_vY>=Q-=E*%BPW1=-9#4M z+j(pM_d5}yzy%wMmJoit-7LdK+JifMjaYX2hg_Ij? z3f6UCu-k=x1yBkOMq-U~GV`_i7?-YUIyaqI~ zHkNcciX{bRsTp}eNr9SIh<*$t()NO<^z<}99;p!RuG1LfYqb05WWMhxfOc3CVdcxB z5m2Sote2Ts+S?}3*Cw&ViDx9UY)T?}K_}O8yB}DD0?g3=M?tc}&Oz~#l(X91Lr0$0 zYieP6yk0s=coOpNgBuoq`Xk!98jQdky*2sR-0M2AKb~i802T<_B)Gr>IMPAOoOD#@ zlu1Uwuv#f( z*{gXf)9FV)RNeqB`^%#k&^2;x&HS6(70r?mV@Fw%ue-#CcU}BtD?>ODm zau~=lj4d;?B?S#U@G2OaWV&zqwJF7vVMLrMVm~XA0;omamM_d5V_N<#k(3;Ww^ctU^hSB znt>8yYP(##M-I#lafv2Bb<1%4!KxmwQp;#3lIF9d){;6?Z84l6FT3_2ZHK#l>K?Jt z@@0HuT7l*gvW6^D-5)B=q$3ZII}La7r%z7@kqX^7U87_(GXFvX@W!0g1h(m8L}!j% z8?O&Ss?FPRa7ojxu%B%JK;%E@c#qzfF!HSv87z&muNi6tx#RaaF+tA89#@9siPfSJ zR{c`em3d=ZXs8i4c>{Fvd70^l$JaXpsf)7%KsK)71*?+^TId6HrHrJ6UXyP$08Sc4 zB@xMjM?P#1<}~)S@W-l_hpG4D5oBs3c$fyBu;L>+O9tF`8-I5SZ{UbY2w?c(+>NS} z&hyP9P^T@at@!=NH{M`6{=jBl%asZoN3C7Exkj~Y3ic%5I~|;N=oA+g9lzY;*4GW@ zirU`eK4E+FVuO850%rxM!)kA5^y^2k7~pMPl7v?1cEc=g9|^&lo%4Q`7E^~YaTpzt-l149<;?NBV48AUoNDAQUL zsDH?9Wo_P41MpA^adBh(`pUA+Ba^V?6vBCbb>IQ$RcYOEw1E;S!>>DtZcjN`hZi)} zconiRYG785up25#o)<14GP*gx-BZcUCV4jqo<9~5d8tM(BW<9jCLN46x%7O*we#R~ zf4s?I4sn9v>PRK={{85<)|C+MR|%TALA#r(VnT@>OJuJ)gv88;$BalB4h2Ya_eo{J z%GM61X}K}p5XJ(OcK-!jLA71R7x6Y^wwfRrYb4O=9 z&ouv>C-gm-wR{~DX*yN^j_;MFXueE#qHoNyJy2Z2FB{(Fswbc2Z*sw5b`d1`=rslmk=%c4J z`yzq*MD8oWN1Dq2>WcQ3f$Vbu=D7a=%%8`)n3EuQU(E3up7+HInCNWaR*Pbberu3e zOKaKnQ3;M0oTdVN2G6cS@E!Nl(2;!HFtZ}0*CXShM!FhBjF}Enmq{&LE54#?t2NYE zq&Ij}KviEcN&OvGDPE*y=5vBWzU`w~C){7(J`am#Cz`s1@o*PgQVqW(1bkLajCVqR zktYX;uOrZY51cG}P+g^^(hi`KHCH-3PEJXWaD3d&n z1EZL+%HP~jLWW*>E_DE867(djjg%4%zo4ZPtv7QzG-{q?T;*4o^y3WIFtl+U3?TN4bo=aPBWGqlGm-L%P9+};Q#+nWBDNI8w*;~NbqrheiV&Rx`E z%fOOY@OD@rxe4RXEtjw={{}nPbp74KvN+KQhr0OX!2@aKhGj;7klZsogkIgKj$eK< zAw|Jj{p#jYk=Ys=vkm&6YRBK_AW}ZzA}?m(h62>5>ZgOn9c_ov9>Tj|3{3n{Ezz3B zHaS=lN&L`8LbHOPKfKbk+H|$U|LNrCTcY_I{LJCUnyKyH#g%6V-hqa#%qml&tzic> z6np8JL2NP;C1~cZ=9NV8XGDZ#1c|Z>@jFEJ-sqdV^y~^B(T31L8~M;#X*ad50kxyu8bkQO;JDGE%GAVLDhhHkW%Bz>Q=G+x`wRC9gI@vQMU!k+3 zF=;Btki*HE=%^KAaMbcne7W&5kXu!RmCt+js|^urysVk=wLQs^hEojp{_)ma&$NkR zScZ~?Hox4Z#%OKA%!__E`mkZ{faj%UWWE%=Iw=}r5F-8i^$Ik8m1S&aSiQw83#;iJ zecIiIjawAnpiX7SxWz(if_t@D4et+>4sB zx|9qHutH7NC>4OdvY zkz!L(s3tmgTl9hmzlk+X*Z$!ZT5VVpB^|C@jUBq|FA(W!3HdVETHCv1YFVdQT9js> z($;vlKF}S>ZI1R(p)_#w`7epSB3|~NUQ4hHb6VH997L4Ec!=?*ruNQO?SGQG*&!P( z_*o*t6Nyr<_Ast#WULn_2CLAUiq>{UmqQVedMVaYCGB6J*p9Yv3Cy3K1>74lw>6n{ zFLs-`TC%a*rDozblG$p4GAJTb*jrxjg%w+8QcR_D0N(8&;_tVgSExW(p#_O-h}@-Q zlwVTgT{n5SRwCLqts!cl>mUG|xmWV?L`AF`(NO8Zf@#U7)0@>+VXYHS`l z+CcB+lU4cTWI9)a!lmQ@pd&qXpe|#6xZlkxS3b~sEI$)WTqvwCs9w8!X5-mulc&x3 z(VY5&ee*SLKxW(J`+QZF+_RuSUZ|Eg$>`r{@=(6DlQtL%a6oHkx#T(0vCE$A9jvqc zugvGezQ>Wg99NRSBOO3i)-wwiAowu6poYR$MtS8el!bX4d^^jKowB~$+LQ5cE86Un3?|y#8e3u^HsNI(kA=k@39f_C zJ^?>SDf*cxe+D6ZMba~s!^LH_+HoUc#w9#_<#u!BZ=a*+u)tAsfa)OVve)MiWj;Ba zgHG%3$ggLWt>wXUY6@>ERC{aXbS$C~U|O|cbZbDzBznGFf{vaLZqw)SO=uilzR%Jn zvPcI7yR%GL}sWNIoKhFyOOzjh&0*VPxES*8iK#^&A7A07^!nMaN`r3?Q>q zE|`W(UDT?(E#NO}Sbj<7h9UrzS`0ao{~z|=J1nYfc^g%11sGk|Q+Jo#DeFGlNvUxgcDsv~lEx}V zLBXDxSLz!Uo(>JKHagW@(H3tuD1RiGoEhoiU1eWhQ?uT2(d5Cx-&0Mm>*t`^>JnEq0-+tksM3=Rd??o4U5ve42Gr)Od|+Jv1l~9bL$CKeD#82 z4N3HNEUI5Cvoh5gk-+jrG(R#az`iQxH@Pl`b6Tsta{NG-sqS8%w(uzahFx`;&g<;3 z3${#FGvasu<h*abX-sN)jafqVqI$Tdznow_TUmaVlzkZo2jAkrZ*t9b7AF_azJ+Wv^Drk@*u_jkA?lUDxdFLQoc*HBdPL{rtF@_J!EMjB zd=!3()1Ii`3^nJhyek;p_H6u6&+n>gK~GcG-z%r9x7VGUG~-&(&KS|;4*kZJX{gg3 z84Q-{Z2t7;=8#sFr!H4jG_9&G)3kpXE%GFjwb?j0IS_DQcyysoXLB|?d4fxl?yY>C z-}m(yZK$0JN#}Ki^j0)}h(v_B!@qE)#Qt`URQR==O@KUURIN)g^UIs&-A3;ugc)uy zo~uC#xE83P4n=vq*if)othwp`8;`*nMv&A}{cZM?4RAe`M-tq-!`$teNrwiXhc7YC z-0YbZdTVQw<}PW4r1Mh_u)T@NI3GQvBc7Do5uC+fCQE}I4A(v9rBOUI<{I$KXk1^j zc5&J}z?#jPO)&5TLxA)eR`Nrj<;9o!jPV(>r9Cd?(=2vZ{N;s%mtHsAz!z%U_yv6D z=rM9G#$0dE1(WQ9XkTd9^@6BLM<{ePKIzQ3;K+RbAjMQ5Bfi@#+#sISz-_9vpFC}} z}^d|f6LjF zVDmV+nJ@R1-NeOm4=CQZ@+4QdQ7a-zY0$41O?MxT9~(< zS`XqnrjyTJDV?0!=kiD7)C-S}VO7G!f_R0H@kgh74Hw z9sB#)^`F?Q^HGrX^t?l{Big#ls}aEHMxKtpCs_aSt@8jnPwcL_Xd|V&xxUg~=C(Gf zoH&1f`+5KVG49*6OL{hP%AspSjkCQX)y|*jvhIF-H386$h1vx3f8N`_5zT`dKR!g1 zSm^O&Axlgbbvr^krFzin4&thBseVGY`6iNLQ=scV1&PyYbS&@mN%9XupL%^8gwzba zfz6C=@^`$Y@Q>w=JSdz6q%3J_=C%{!a9vJk8Yik1_FinA|i zMnhSJX}e;<@x;uwF)u2gq#l@aNQn+H6%?2H9CT2X22}=7?wOi~{r5Wm=gZR7^Mb-q z`)-iiD4sClF;GM#C5HZwR5p?Tizmt^HcE@rwoW2N=16CeyGGQ(5zKmSHavl@eEveYie}G}Bkx@n!itf$p(vy8T)5 zoUI>)Zmf z=+@4c>;bQ{50gs}9^d{R-8Lw@#JcbODnfV%!V~KvxoJ;N`<>nlhU7=RVdqN@UV?i) z+WhUu&Hw!mFL|i$_^lp`+4}qc{J1f%-Egmah<0c+Kp*3nfQA1bBl*uA^8ZdF5zIWA z_?H&IkN5jufBcWZ{N26$k5~EO8~^&_An3tYY#=}D#p#9S6`)dH;8H+Iv~?}x0`mZS z@zm=QM#2t-qZ-i-rhc1{^&oP=7Z!JyLk0Z%AHJ(SA3KIHi^ur>xAfxA#o zSSnljx^*R%;wQO`KRtN;Zt#+)`5qNX{f|8mtloQtvXQEriE%&~n@ZcbBu+X1si7ueR&g%8A0vb*H?qIGW6q zjkOe$fikA^kx^Sc7K*;wE@F9>ef(Of{pB7Wd21C(NdXYFv}I-pu3H)`)|5Ti*qZ7) zP@>fu$@e|*<^^)hwMz>LzbYn6_({B9<5XJZ$W^3vD`HeVxdKSbNk zmaIagNM>uIn3y;zu)~SCM@qE9Sl91CvC|pMsL--#*j!!DT%zW;_!n~Z#n-DJk8^&W z2Whi}g3@WTLRXA5B5Vd5o;2414%$4hF|S0!bi~qDME|{Sur~M(3LU{w5Hrm`l5>5< z9+ui8uJ#K%k>rso?TA>GgW51x5bFxp5m?_cqubnzCm$6B%R zvPOQ2rtT-_$;3dpHT#ox&1+0f4O8z9Mvu29E~(CgsC81nBQ<#+p+GgskN(b;2_dDF zJo0S9dC4q^$44e2GgJa%Vge^7ujOQ&(w>*`PZCS5o zNEGwXxR_ZGTrykBT%7(|x6<1dSi80whFOs;c++m6P*N2ykkAIkFV+f%%Yw#oZHr6% zFQkfr4g!I$8h`gPf8N0fS0W-=AB*~4D;kpe!i9uRI$4|6S(+6`^_fpiV^lh&n;t<) zBfxmr%Ig#@U1Uqz$4JD4D&amkS~;qT?a@Pioez%KD`f$Zb;`GIEUxWOHb@?a|Tw zo;iKxyI~o4EK^$6nyp_ws;hvJwucIJ(E>Yk zI7QGHod1Aj-vF|&v{96kVbRKHI2QAiE*-i($7wdd(6oTUS@`>sMf2ot+qLO}L=XcP zL?~w4#2gOPzv#iQ}c}i4* zQCtSKdQSAQnAns(^4T;iBiN0`go%xo`#Xp-92DNo9kFj9q;N=>{8DAYUGwGe_lcM9 z+|1Mvh6zzNkuZzxjd>sBFiglXt!Of8Xnbe)SdkKdVsPt~PR$oSGDFq=a?lBDc=(PM z82xxG&Bom@Y)-EZCh4%?RF-5G%30+Rv9A-`7SS z7WE>b6vI(pmlN5T*K=^}G6)_iK`n3m4;3|NiEfec;nZZv*5TLPUt|C~rDVNV&VBZU z<~mH6lV*r=)Qg{B&MbK@`JIVKCSKMQCT8|tcA6cQ?3##tQ%%ArwhE11k*JgS^f1mD zglgRnTW-{quS?kl7OiF9(T)?ZjO|{ZFj$=6x%6U(4OI#oDu(8lGpb=kKxURAlg24} zOO3b#^CcKHFS(w$7Vpg1->KM-w*_KIe7mwnx`HXFze^*CQ20rg+=!4oXR+}_TQa{$ zP&KpN{ljO%^lp^`pQfxud_k5}7d7eK>$WK0&<<~2=%m(?--@tltPwjOGCy0#uA*|! z*kkbBBiMmT>^UlZcg;LgRwLYY*ovpoR@9H^9oa>7sWJ{VL!*``>%{WqD)!kHdl+JB z)eR=fywWa+jz8gE^Wy1=#uMEj?>Dy+vRjD=dqia2GQJVcFpWOV{sEvl9Ix|z%*AaS z>&H{OJC#H|un8*fD#;YP)4wT@9$m;is9kT0;{uzxu5FSjA1;*FniS=_!`<-_z1`QC z{)U3c2TSXHEBk^Li-E&j18*Y2lO5vf&2bLr1G{eI39Y}S*?gLVMrUGgGAE8BHAWJ3t zpmyAAP3=MZTC*c@d|CR;UO+$8SPOM2zB7trrA|gE1IzeQ+kZ0vt&nZGB}#`rXYbTH)zI&6{qMu#N3silWX)qr-Wrd06UCaq;4Fn z@D{2-ed~h~d6~J3nzsbvnZZ=!7CnQ^(#7tGo#UjC_nPv+>TfQ8!7sp_Q zIIuNNi+djMUZ7lYrF6bUDlf6IGqADaS6#MtrhdDdnTV2jUi;!${k|o}49| zoa*Y5knkOPY-M@ejMfswHAl5Ifj;AO@gq-<>5KRNGy`T$JY9EQufMku``H2xiF z?Jcyid6wh=Y@GxY1TY%jDTMjN8LJp)q(0u6&S%nNN{Xmfm@!$7G5Lr{G|=Q}ksk?!YGPeSP1+<;Mx?N~puj(^J-yM{| z;A&f(IW-s!mF;+Y7`?IC@3cd^DD5Wq#)ZAfr6X*u92J=R5fHfhsuw1T`4(mN_pMCW zWb3${UQ%?^T$Cz&y)k&a{8R08YL9nCfRT26Ugy~Yg#+nH>dfDPRX67OQIh$}*h#4& zc==f4-Zxf^h9XzOwV_*pvQr)Ywed{ra$cxaYEow!J0C-NE#aoYsWo^$(HEgIFV7JS z^kT{?pvn=TlgD-E!_!(G$@f;b+Sf)j?X(x%ld<`Ik<4a+uNzEH$+h22$mhb4%omE* zJ5I=`+jkkXI!~r|hBFnKViG%G2&Hyss>(~}1O-g+Al8s8aF$3$?2jOVXVWwT?dLXk z{S*mI5Kfd-Akb_&Eg{|-%eDh#ndu8B+2kKnbQl4-isw#|yra%PmD(U%5*^!raJn~R zlAo}pAd?Z*fgQQ<`zxORbRhrTgK&lg?yb@%bCU4;gNwb&jvlp~I3Q;B!w7%+LqJuu zBH4#nr<1T_K=Ga1IryF9xbDBwyq~^<#5T**6H9!gEMsRNZ}k`W9d^pRYda!rUv+oy zPCR_pRf}*ygO+OkclaHhOC~-9-xXShX5ehgp1HbQv@_WE^#J@%+3mJ{J1%~V&t6P~ zfa7<&pGYP@UO@A4_#L;GNrwoZ!{`MPA0Xkg`~G=SVCUaq$6z8>e~~}6^9wvo1uyV( zWvAWFzw^IiC>&`%YZ;RmCL)+|CdX}9e-q^aHly{o5CcCVXNUd@zr84s5`TQ+nYjPi zNM%&JG!1FKuAjarq~F%#V7icY%L}rpL{P_#=hS~62N8b4v-1)O%X=6J?q5_YnW0&R z*|w2}jS-<2<)R{#wd~q+!SOF?%0J2{KWb@``lT(jpX41)B^-;K4UvMpD1tW*H3%A) zu`?iSqo1#MEgxBr75A%+UY6T{_*64Yz|Q0(uX)5)@4&Tzqpf@bceCvBQY%1&^+j-_ zhQ8kwOn6ZXA3*@Wf}Hf7$CSTO#@h!+kPn-s8X{5dx6liP(H2hee40pl*6(xRmCuVBv_~HOGEkU zL@HzLjb=D+&$Y(zlj)z(=La*n?6Np@&1S$n;OBiODoFA!=aXYU6Wp*TcFjNgYzN7^ z`uLD(1@@{!7`fCTNS}EKqvyIevRjwd&g-Wy&~4uSJR8G*e4a(~4acD~MawE8hLt4Z zZMu11eqA4;lA@O+wC3~WqE~(Wj9x3O9bnmPE8SRwhJ=az4_|j07}rUARpp;njwJM= zbs6`Q@Ue=y2A@?WGAuoqHuJTqn6qOeIn|8NP7{y^??oY@6+OA11=iiXtbUP#qhp#k z!I{v|$BLF?M=jNhHZyj1LMP@?8sSc2x1&3x&9VhA6qHe(y0cT=_gCUX%VD_}3GvL= z$hmWYhDCWw?{M6$7uNlt(DnvHJMJf_)y(u@DH?C49UZ4KZDXk=P}w zT^}IFKHd`RFC0e2rm4E*ni>jrTeEb!)A(W)mlFa8!XT#OuF_!2=}^BnqN@PmCExMr zOUwj3)mK|DPMKB_o$sr4p(|Cx5FHHlND6r(y3iN6}LrLuXmm?m0ekVBTA|3jjj62fQj9Fm&q2c4|A!@F;7FSFa>Zyp^f(b8;OX{*WRzRzqZ5kx$=~Lwj;H_$zXJR61 ztqO0F@`$-l7b?6b5blUFkf!CfNHPl3e{QSKd3WhlbW*4EitKD#XB1eXGmnC|XaNz~ zZ;azP(Ho|#eR%g^pI=bEAGMpZdPWT-wV1tA_ zI>!b00547E>slsb2z5VrB}M~eGY)bVZbi$3zO5ifrpY+a6A6TN)-y05Ezl&~qRgpm z3~-@~k^9!tVOz!1zRJW@ELLh*kMEmNolPwyo=4W+~Iinsp-PxrvXg zw~SkI8tYFx*~^~%6TDRBoIuH4DaD(CV*%bWeT@8_H!Gey``sC)P$lvrVG+B1(eS_q zXpKpN1V?5oKq6rptHTT;p$W2)MXIUrV1^=Mk??c*qiT1FK%>yC$iZN3XJZ6m z&26!Ta2ZIh7I>@`RCV9u(VrczVf@C}vYYty({CW=@7jcNJX8v{cE<#tb^TshvIW8) zirz9hTdSR@Pd?0j`7O<;a1gb&$V!czb=T&qydg&*Ew(FYeX}UTo{)oA_977wW2%f3 z-?%h({u5;-<0DZE^wyXYu6mhc4qyFVQR3RqRmbu|inV-wb{whM;ONKbHb!~NYkDSV zIHfMtzB`ySVGCm0!*5juETY{OlUd9PCQ?!ppqb`;G@A6LP{M9LTh3^%fISa|vKJmjyuX8B!);~-?#uwvo(_LM?>8vN%Xv~X9rY-6!! zZTbv$#GC%jg-?n%ObI;8WvWYcLsf#2lDCudZHKEFyk%ydk+3|1G%x6e%o(CX7&;Ec zaAnnBe=iB>hiwvs`g^)tVLYF`1n zI1|*96}*T4*;ZkbTgIYo@DTkWzN!|dXSJnv#<`W(dR0oWOhK)whrF*OwTyMObBDj0 zArE23J5J0UFd-leD|(9L2FEyf)4FI?!%UWN6+kO|^%>2bp5cR|8)lG{w6x@CBi zzgP?-ksO&RlheYz5A@9?6@%>7uOi_+V0 z0bsQ)(l)+oHEdr6#Ue#9#0_ zyEh-sta(2R*Yh5FHH9Rj0k$Bfln4~H($kA?{5)HSYhJXf8AS$`&BwEk$g$Kc!5ql= z*|YJ`dB~HV{r;p-zEWm|`n~kPGEi@fLuXu{>s-SM#a6{T+wSLj_gm4nIsz+?N0pV~ z&t1QfQYZxYbt#car$XP#Qv0G7-dP1JV|V$fjN0J*CsbvNN2*o| z8HtfEuWzjy839Tqbj*&>40cEz_dTk3+IjXVyUB?5an^}4XNR%WrYiRZt6uDaRTuk6 z?V_*Fx*l^uruhLPY}Cr+$>@9j8p*#aYj36a3$NZi{HaZQ1&C}Z4JH$g-MPlb(Bafz zfX0$sHfc+`Y|&prDOFtcIDzk*FJ;|4>P0thS5j%HeA!}H!(QEB`rsh-bAa3<*~E^s zX@=mP0+p1$+seLV8w#yz$OiHypIeBr~+>*6Nzs8H!_p9Oa)P8Xb z6@PL%v;!#aRbh`uz6-B)HL(Q^Xo=SwqK?sG%15CRNK&YH_=Qx7UO=73aK4HQM`oRm3`FUMF`OV<={&%@=Nmg&D8-^NaF9}OS z*jFV%IDFkrB!w!Y#x=#}JtU8T>6JY)-ko40@Ka0Yzrd8RqbIs=P>T_;(I3aoWlBhk zZw}o^Bz@Q7s9;2LVzTjsQ7Ivn@6QVqM=hN<(zJ{{53u+xu^+O0o?j}Y-qM{vrkC%7`$6PLL*!yRK<%#IIUc4T2f@x=~W9*}lZmpYD~Y|Q>Nq$Vk=@aH((C1Q(2?oX86+9o!;`ZIP$lTY-&J|HM$Ef(Te0 zqxcUn-Z;2~$jw9lz#bI!g-ck8Qg+VezuJP`$!_iY2hv`Q7+gZNTj}S(=`T)`# z=&|P?2$jvR;1XZYl%4Sa0WE+jTFd{5$9NYZ8DgUC1f2B0tS}OC_O|hXT^l>!Z0;qv zgf3=+kW<&$rF3YCq!9AM?3FMfh_j*JwE)73Ow*7R5XrTRE)sN{&EDXe z0Zh*iu7JWe))fV;_Yd7$Pqjh?ZeY6cZh#)LO*iCZJqKO}m#RTx?mYidq7_=Z0<}RG zJGa{;bwuq3fGpqOdoRf?S0lsp>VucD9qvv<1e>t-@ zFDE-^hAVhgd_Lv~)52PTUKA#1Is8l_fkN(T_OC|YAn@M?d4Y{m@N1|npB1oy;_KZNjE=@~4xTq8cC$m^+C9H&isTdX-*KGOY-k_^Ej zMhxrT`}qc$wm4Dty-1N;(hs7r%T2Z^cRr2NaR4?rkB5AA9BJh;RVI9ll}H4fv`pa} zsPM`@IBk8i=(G0$CZ~-&Y*4eWXA5WbX)Nz2MSM4B4r5TeFQwT{!Y5AFFr3j+=81-< zNY|IEMKb0M0bdalDHrKYvA7?>s2MLA5`|9k-1J}DjG71t#L{v^Hfd6hip)eOzaJ;wRV=z=wInxJWcrY6?FpxT4l2qy!jba z%WoC*T1$PKK@!I$r2q?uQ9WEbQT5yvLMZJt+4_Ar8vv)r+Y6H@D#LHRxEK%gP!?t0 z{wv1}UTruh_hJK*Wgx;!&FxEaH;c4Btqkpf!E;}hXNFn}$`;b+K|hwBHK(47pS|BB z55}s-BVJrpTH;NllGp-q<*fVqrHfWH^k^vAO4LN31tZo%qrXehkVz@{(JlowlgyHw z@iwenI!mcaZIGyvQzEveUo!bI#d=1G7_7-ic4CZ{f}vQd_VXe;9tdyb^FJ#y)WDfg`mfNWldRggSI zv)cT?I&^EjX)A453I8rF_Ki8lL$bmIvQ@^3s;SsF(RW>d8YPMNFU~6?!Ktady_wq1 z-;qe~$txy`eXoIvqb&ea>1PWat6M>}_%`Dbh)%K&AYH*Iy_G)zQ5Bd5so<5U!6>N0 z4WE`yRlJ);k@K8REQ=A=B$0$iY!@R?9vOqWnXe@~bB9VcC-)al*iZkjUN)`w*=PL= z84mGz9qP`0VRr90K6s+ zk+S*#6IK#1EsD8wcp zR+L!9CBw7ZMFs~pG@pAM@O1~An!HeaOEf?fZ0W!r>Z6M0`1O|&*9G$54V0BIt9pyr z`h&h|3iOdK-wGKG=@tSKq(fV>Zf3Jdjfz#irWvbUX%Q{ixtyAAc9`$8XM`JIQzNet zjj{-k01r~C;k_sME5cPq=kzns!cTsMh@;W*w6r~myp}Ty#oi)~1a`x7Rn{KujNe|eoc}Z>n42=m+wI?FG7>3k)#sedBdV17_$uMN zGKrn`v*Li5IWb8uA_*JxAV3@8owZ{ig<4C5I@$XiF0{|SlYU7OARQ@ih4|6nI-;KY zNO0QR1-+>TrP^5s1N%l13;w0&5Ml7lr?iO(UL3TJ%D}+MXwIgY<)_)8N3ea zaw0Ur&g~5P_pzmlh7Rq5Dv>HgH-s8~!+ll{cod@>tBcL;!3wr_=I_A2*8;Cx&2Y$=2c5irt1jv_yL{eUq&c$aCB8Jzty%l2L5r#Uh?)h2;J4-f5(a5#yIOyHL(>> zJ-=7w@|^CC4fG(6=_;}$tH4n3$N)4-V$6X>tRK)+>!)AAKe31vnd&nZ%{IoqPU{E7 zt%$mJ^~n>oasi0ZwN303o_~2{B!2#tZv6+`y|dOI&KUy)sMVe6kcHT4Q;6udAnQ=$ ztEN5@iKXXjix>4JE;Y+3*2VVXAeG(oieTP$hV^@AaH8#HuSSPgqAV)MD}!%x&c!<$ z7Z1+B1Pp$>xNZtds7hmdkGSX8SH5hum2n})l*uJM$rwQta@4Yf^QrXfpu+`m`bGq1 z*b+&&Q00}T+TYPZp>RNQ^qwG!d0HI+VWzB`s9N)f^WA_4YuEK@0HEf%vEL|UKh({m zcKUJPvNhqYa6H1_y^gdgk6WL{$Kr%$7#(df^G<J)ENZs$;fRWj*X;hq(+Vft)Ej8`6WJc-xllS7FW-d@Q>qvIm z#4xmkgfEctS~iJyyNwfsr`VEWCIuGW6Lm`qDmYzv*P;Vd3%0`jfya`Ic~&Pk^9a&y zz3h)>#c2yZekDAwt_!s4!bUIRv#PwGC>hR+Dta@DY^^l8l)MK%OABn$V5CrTdcDge zHJzz4GO1+kTk#mrcJ*@_o;IXl<~-3gdMzl#A#if#JFrw_Q6$HV4`VV&DKJngJpy%a zenCSaRtUM;#aa+q^02_yn5#bHaJ(c(_=-w7H7SsWD9LtosR5hb<~yrw)xks(CNu@W z_*-vbp)E_@ab>b3=J83LE$6Q3Rx`{4Nl|t~{wtDJZ10Qf^-@@mj}1ktf7F)aerYl( zKu84{SPSKVL#$l1ji>w#Bvd-?r(ewaQ3a?r7mn4HXL=%hW zpE#79k4BZ;R=_Wdn8TXHb|uC+CSvVSWXF6hfm}01*aIa#K-;)%)DU)sxrr9%@|b=r zWsh3Bot{Y0haQM}Z0s@Fs8ZBT=XCKli$?(*Yr{62%#1{=*>RttO(rP#y~eMd+uY8p+L;FA2t-XFs?PE|^apEi}TznI_ zS~4wbZdvKQcje4&gpMN~Yb!9qoQEG)AU^I3)Hl67cBW?*n08Im{wJ?6RgpWTCm<$` zQ&8JfR}L1FPG?E6wBelannk`q@hNNVZh|?#DH^)qGQ~_#Hfz%3p zeG6H?zAe&vm04W0(3lQg9*n|L++vo%_4rPi6IrX^M0T60GXAxn%b}s9)>D)5T@sxH zExke%2#F^@#18e7*q;9mV|zqU?3v7BaGX0_>awmm0|?hR!$xg!(X&a7HD#Hw`& zQm6Y~w0}ED`g9qZc5G3e3f^edu5?qhR2A5^)1swvO9atvU~&L@nE0_R_zC$k@SVa? zF)<#td|~WrdJf?%k>fiQqxbcPyg5%KLz)JX`Z9sohd&`ff8y`+1r^PkWu804g4@@0 z1A8jDv|0sE82m@qY#=BV|BmekZS=fbMfPTtr5#Fo`ex_Y;$7@8(0gzn~%s%iFRPh|s-neqJBkH%HURHWbD zClzAZkaya^S0t5hj%M&xV1V`(1){}4h>G$4$(e8jEe#%tBg8v|UlBU+&f{}5@aDq! zZ4AS%I$5j3p@+y-y*6U>-I8Fj>G!cl&gYMMV4^`P^)e9Yb1V0qiZcjOgUJ6$d`ExiokG_UK`YEg*g42HXg)o*QJNP##DB+XAqH@o)mU*0 zgf_J{e*N@BC8F%dsL?4olALfoc0OJ7s*9gd)(ngC#6j3Ks0x}!_RxVRJ-^%JO5tIG zz?^7I=ASjVND&Wroh!+ayMZ)+*^{_BeZjxt0DH+(SXO=N(#H@2gQ)|=M0NfcF706(;Y2n})*I^ZT&1676y zyT@%GaQtZVOnBvAS^$=z5BNBE0(59FGV7Si{Jxv>mn_ru!D$UZglyCn%)? z^1R5w)=MDUmidu!Wru~7N)h9%YS(ga$@I@bxmH`f-eMF{ixbTn8zf?kiC8g9K5S zIu#x^u^b}rnqTpjE#KO}Z-Y{2oxi?B6tA&$(6Hu06>|I!6PTmXulR1Sqsi!iV~;R49` zJ6Lm2gW&qW=3;?eQyn3Sil?X?5v9oRXn{v;!9%gy3v(eVX+$}|ryWA< zgqxKc_P>)(AmIvh;15)jYv@pUB8o~!;#{+&^IoDXsSc=lGHB3EcA*8ePUd(3fW~GI zPFMTZ7F2*<%oTjn^Q~&E`zyHnuh$HW-P&0vgPxw%T8bnHkrm~{&Uy(`@{- z7&{+BWQn*|cdyZUiy0V~IyaPTOk2oRpSF<}wnO`HW~e%ml;UC>Dmw%kEF<1wF4T}Z zQrY|o8W4tr#SWGuUKnGpn|0vv3C>s5Y^K059B+-@w0&zG4fE3=Be4esdd<;C3{f8- zSvX+_#Qg+R1^QKs;V(n{n`u_GP@TxnwWwGiJ!tO*SjNj_w$s27XB76^c0H8QstR~e zV3xU;o~MjRS|^VM?|`;o_Q5l^dQ$J_{pE8e(Rqm{Q;U&u5j*{?C`DFWzvwA4f{yvm z1vqgB0&5@Bie5H?Mv1;zH-;44eJ+{R4$30v;JtPc!jcdB>UU@jLcg^y4DS5@*_+8{ zF9Lr=1NShfB@H$MJ1U^HMOYCWI1V8a{m~}fA+;$WC^f6dm>BIs zr|;)Vt9u*gIiOsSi#YBu4kW{aEk=oeQv_T5u>E<^P%*akge^mS@HHD7TNZ#1&=^Y< z4;@>q?g~5L>$w+>BK{SyzybxXL!7|PjJ-h|88w-h@L^BWAwbgkcg_(yA8 z`@qh(`UEv>e*MzvMA(wx*F`76W2B!l`MDSFPv`+|^&?zyMreH}@g4Z}yNKy!cb)aW z&X2#Abo+Y*)ZvQQ2RdT}1F~(jAsseCo_W&Voo`hFuCSZ`K6#b!-bX8s{C&rvyzMU} zxGZ1q!WB=>x<~#4pA6w7MK7rc+bw#WGpumM|BsC1M90U{%<*-bVsnKTLs^1eWFCSm znhw85>;wauUCQci@X!A>GpIz$kdL)6GK}C_|7qNraNew(+}SipaxcJYl1JD6!*>8v zde1-nH!JDEd&&01ii<6o?|rV0gdU%($*WFpd-IfghY#sKJ#e*P@}f@(@rK8mA>TrI zHuKIm*o|yA{ejBZ&IYl863eak89UqE-Fskn`De8wcUn{ECr3T2Tc?Wu!Lg)+W9fEw zwEG8WZ3{tb7E}9ocyjPx`3PE@{L;?9^G@4>o%Vm)qMYdHC~f`Mn#834-(qcfb*e#| zsNFt?zdpW1M5ek5^G3BuFDn%s9h2Mzpzx{^lH0;WrFc#K8eZf2|Bd8*?yTQfAp z!&}gwCCS@oi&Zy{4=fty6AYFeaK@j6KRTpPKrr#WTnCi;>vUzhMO@R$tA9HD{iW! z&jJEoJnA({FW+1pM-}|fMJtC3d&-u3R7xLfm+E3eD=9|?vEhF)!DgTQe6XrhaJ)^v zB9cd(Tmb~T;0$OLa9!FaWg#jUG)CxnoEh}!_gJaZY2>~HIh95H^~3xZP}7rC8)7bT zpuUnAi@ma-ZB}M>`<@$9^Kp+g5Q;-7Q`NV0ffGtW^}e$Pg{2DTp2$HRzU0CH(6G!B zuwFlFFwOhGJKyk3sqtbzir2$Y=2!_sx3LP;`*@-E#}@LtvCvyquF&!ME}{A|;^skH z?WkOR3{G(tq_$+e zhq~9;8KCnzWWZ(tMktSN2Cl!31RyThz50rzM!fU_&$khOk9B<<{qo6p>+<%CNS)3Z z3O)*XMs5B>!0lo|1MIs>jUVw?eydlx@!-dT4 z@xYq1{wcg9&INBwT0ASLu}U!#+u)4PoosMZ_7}6$ap`8i7DvJpI5Hy{Np6a>fifEO zN6amNs!W&sLSrD0838N#R}krQJLc*Qv&%M&hJxEn1H`98QbjIPU!XH3VjCMW1Dwlv zYhy_cbzaz&L3Tb?vQ3rCwF0I2;S!%C!hFQH^e$GQTKc3PjfiklK5ETYVH~J$Jf@=g z3{_ZB%UZ&o!+f-6g(E>L=cb`nu8@2dpNd<%*B0$qacXRc;+p@>nJm6<8$jfccy!BzJ7)r3i z@h6AM&OcPczubaabjt%^q#%*1L`2kqr#3Gg_CB%!@dVH$ceD9R9}RYnsdl^$t2yIO2r2kQCl#*?qCc z9xN2qla~ zd$@1Itv&sV`B$HWo8_yWtrp+#w|eM;L;~j=1ZyS2+sngC3!9uh){1P49{4a~6E3sJ zd&?Wtlw49>9Ma_1Ir)7vf$rCzD$8GWplNd75$J7^~t!hauy>K+l7|=ku8ByM|m#SNC zqa|#G2F~`@k!^*~)08-Gvd@nM9D1!JqCzd9)i(~!{a*2MxoYcAJZt4gLWNXbw7Q>6 ziUGIg`*{VHE2`egWX;rEcW#yyfN?Ppiw_FFy+ot{+BF}J4904gXfX8WP~=oe;%En6 zJ~$h;cFGCvMeP=+FP-qSCs$`bl%#Kx9f>+GV)*vPmY$*FRQ21nH~!Er$X@QiOmx9O zT(-pJ9x$ZwGaJI~+`s50XkcXNNT|GWP6Q)nP@xXl&4-ko{#L$oojM+FxRDd(039lu z^Ai2?QC4j~%jvX_z1^_g#30PbE9+r>nrNftm7{HC$_ z<#n4C!|L<B9=Z%_`Z_#nTX-|l$C=xxb}HhlO2`9a*QVMf=J&KLX#A!C zt9=8G(+SuhI-y@E@Or*hz7==sLmqv$w7{0SXg{~YO}@uhB?>PjuNQLw_L&R(WMxAW z@vN)fQS`rCt-Il@fUDr426s_6kaEK3tA5;x=;dO{j?o?l&M*YlA)JY_l``}dPU#6T!8W(9S1MNwbs`pt8 zxo)ifalGuQ4<*^ILSUWRMAL~7DLez#cj;-4M;kz;XXDXokpl$QRWm3WN4!Y?iE>Qu zz9QxVo$hTS1w?hUjtt4WY&H09-BW-|&tCR)u!}G8G+BV}=6x(?*WelTY+J4Rs7I7J za(bgT&2R#oHq2)oC5~S7<|m`{rL0UKNqHw&IOt49$%uC;1g~>1d}5K$@M6_-4YG%i z*Bsu(TXw{vBQ^o~@Oc}U?c_z?|f20RjX z#-QswueUdfBoC`9?%y&qy7hhm*v@r%`u8>FxxIa6+#Z|7k?e2RAm@rUot03n!rQGx zRecJad5pJOcV7rVRjzfxlls(4$A?Y~QCIdZ3;!mk6W9L)TWIKNLHCe+iLxA@?dRf` zy}3Rn675^Xl0`CF^{LwW1?OB=;@T5kY<)k@`lI2xxrBPlT76*fX9?17p8aRrM^)H! z3NXRg-~S7kKmm8m5di#Gcga)u$kCqtUj267(egAS(ZOmO57#JCN^Uy6eK)SYn`W!J zK1uTG-Su@qhlNEx_^a(Jy_G$$QlHPnc=lBgaq&z=fxU5(LWZfnvb@^atfELh8t!9s z53PV26*vfyF{x(&1*gWVdLl-tZ(>=oaT8sqIqxwN-6ngCqo!?$bNqB>;}P*7XWgs% zUA|oxADppQbzUkShzx&3+`G^MjL~iBD><<~l8XV98!S1tucY}qdkjeBAUHd{{K#Ja zVjWUwv3|7PcY@bkbA-?PF};{2I4^EA=8P6E@f0s(;f(F~ry;xJJF-F-7x7O`uGKEsN&Mh{~fJ2)xOOWg(&h zth&BByJT2Gc=lPzWu3;P)$9vHC+o(=_P+qXvV8bPGwnAgINzDOq;mZR@FZaYi~3V^ zca;_7%2j5HwXV{Ms%~ufQ}fb4RqwtBH3qAcGG)w|9+iR}u0l2L%~;5Yq9HY~LtpdX zKFNKoSPFGxM9HhZFAp#q5tcf&k>{stgGrhw#V2d#010=NKwz^f3u!x^CGYYF%-}V% ztG!Q%XF1ni);r=JG;kgm7Q#Ep(ciq?ljm3lJ#rIRN3s>q|enrLA_?x}g?Y8vFdsXJG5INA4MeGD)*#piO7l_zK z&Iod1*&a(-W)uvONu3nmAmOQENej6`=~YFtUiI4US<1i)mdKXu=_Ri)&94?Bdbi$p z=b~3MFNPESwcZ3rPhQt@|JSoin%=&7o%a^`f;5=xsDcE(sJ>_I?`zlEANzcQwU4S! zUYYo%WEXwcqJ2c|l|hGu^|JvbpMp3aQQygXLiU+;_sE1m``S!YinH-8e2%t8!*Jj( z`4yLrB-!=G8=>F4W#kJI?N8>h|2&u@#>s1I4L{P*DgRuwhveO!Pl#2zLJ^6B*L0oM z@XEXTK*xb?U0Ss#skS97PE^%Eef~(^@Zllxdp|?#cWFT+3h%5{$uJXakyh`iK{NV> zCq3%RInn_FOBE=NORe*nas29Dv72}}{QISbA9zMj?H^KO4~!Z` z!^yVN&~k(K4y+2(#ig6}Mdq8&A5R(y&xKxP=I|C6SnVj7Wtdum*xQtBw|~f^cFB~( zRLTVupc{3R8ef2;)K_F*rW&K$rFtd=S3)mSN}Xu!Cobg>6dF3?kgVMe- zH;w0OX_tHJA_2#_c(bi>&jzQPexyyJK+|nVKk;ic<(7rgv8}@;6 z$n5T_uZmCTNgaTYc0wzE=<8x~n^gVON#qQ503!3JQ#m@_Y$BKamJM@?FmD2vQi;@? zP6Ufp!zb*QVh_bzb-uuOn-zZ?P6+|I;pMsLZNO`*(gB`X$X8h6^7~Q|dpf`c09;Gc zMV0p6)N@1%&n}0i_~4$Qo)xTo7#SD-L>j>Wo7yr)tbONDb7+O;+UyfQhEbKKqVhzh zM-Fr+-D=OcXr7;^#*8WNA*IuyV{g>mHwJA$RR4>e7TdZEC@*EZP+&DhnLUUH?(m>k zBj2NDPm(R+;Dg;{AomH+f>Q_xW*-^wZL@#(I~(y+0wRkuCJIGt!;YFVfEWB;t2Tk> za${duYzh5dXGV$$B~98gf0ZD_^8eb4UHj@mRtV!0O)DI=Y&lJW820|GS4I7xEUag0 zB41oq{Cnm639$Tctc;oRO`oZFs4S)mS8Ue0Z*BS*`dRcOuk3VI-Syk)5u9@l9RGe> z8g!fI4*11N|GpKv|B^#Ft5Up7NI2uwTRft{Cj^U)!958FO_?dCKPYUYNnh^vN}gq( zub$=r64cWnL;}?E8CvX+)?uL7X?4Eptp)i4^FUvJqus$gd2Bm3y_G&nMm4Vc{(zf1 z0Kex`>+*T(D{c#uiA>D9a`f>;{L!XK^ZwaGzwUnzaTp$WXe;nZP(Za@+N%L`s-8U z;=Yo0_3+PyT5=u(*)J|t-GRXsBu2a1)$peNVdo$EQ<8)XF5!UOb*8uPnQA>8DATK_ zOie966t?Q8yC5wG0(YPZe6MW>_zHsOJ0js+wr0MnsbWkrB`) zL0G{$&MQsCX95Ud{vMAdOHVP#!RpHNU6V6URb8zY%9I#*ob=UpySWn*)8)eFq}WiL za@QQ}suhdbDFJ(Y);V-HSW+NPj-De|F{cbL4=i)BVEGZPlst|VP%6}L_UV6p?!Ue~ z4#WH38vNYyE{+)JT~7S_;PJL3DcllsG(o?c6a5`4mLG~aJFY-cpmqIg_P%*Qrv49= zOai)fZo%i-gBH|C!gHY1sSNcwDJPInZwqw5aPBLoYrD)J*M+%|9R#9gtb# zZ-3znwq6#30yT6Utz*=|gn1xCLILMC#SH&XL1!Bu{%>BGKY2ywuhK@{|6i;*+Xn?OBYJSks~$cb$|k6z9xDTkBHl-6np zDcrR%0=^o>q$Hu$KgOwL=+5@Z0Z@25w|x?9AOMo1x{ogIm8epjl|@j2l~jldr6)em5JHR8P3C9K@!HYeJ;TO5NX};A5 zQV4)F>aR|(;@y0dS@_32$9*C`?@h^0^swc+__OqGy8+%bfqW(H(L#9n;EC?%n9Q^z zc+mk(l4F*OffIKReRGF8v)tD&aWmxq*LwZCA+y8$<7iDGD)0UpLr<(0D4{s3lF}24 zm9yk3k^9gCq&v+Er!;J|ap7r8`~r#?*CBuiyx!If(c%WG+~#n(Q8Mt}wok7~9p>X; zrPU6gL&JJ=g80Wf<;)ObCxD+iX*S!_O(j7QDutj3KfBmRA%db5KYUIQ%9^E@L7PUs zqJ=O%D`Fx_2U6Ug=5l*WK;qMPQbVmg3`2z^n)GL58&)%emC*LpKpjpkqsgAtny4%e<8!)_nYb^W?# zsA?tWGQiod&dH%SQfiYh4pYiVsP#+qo1^l?(WS;oVW4n;PY3w+2v{hc#U-?5!yau2 zW*-)Bd3Kuz><_FWhgCWt8|>fO;KL9CmIoY36_6&TE=Vw(If?d_nGNK{f80d>Tuc91 ztif#fsGO)}#!t`LC5@&K3kCcE;`?cuDu=el9Tx6(XBx~&h~_V_yhneUPGGa2b;RB@ zWI1@hX-gNOqzeA@C}k{*t*(qMmh`m4jL@1E*>>yI)Lkw%#F`L&$}Lk|WOG>&Y`n}T zPDtt<+peMGuiH~Z5+VI4t>;NA2RY{}>>RnTP34$#?XZ_R6Xsf@8Owtj7h;`fMlov(8+lcC>u^?%TEjPUXr9z$kW~kaixzG&-euA(+~mwYEm*u z5bC8h4IJld*&*R7Ldm?g@Pr!C_cpEB6j^{%gH!765(Y`CBZLxYXD=I`Ox=2}sI3DQ zIonm=s^Wk1^L^+9Ouuf&^k}Dc&Fi`Ass;xdzA$dG5tE;$DP2{IlO>1IjyV@_GZM3U9GT4jYQI1DuTcyPtv*he$Xe8(@E2 zaromb@$@@bZ3>dwUJ0b*McQqG=-*lc43U%@ez zId-@5`&{;8q_x}^p{I!voRc`!(qC2e~U~I@-3I{j=8luo? zQpJ>Bas|=DU-&4P=nH*e%(C0R0hebMAla$v?GoVZkWmvVg;gj5ZJSiLx&j7!z5~KT zFTpRZPRDIamFE5FB6zs%M0M;^b%!guJ^A|DH=T-dx&`c8ZeKMB0gRj7j;mOuQq+PZ z{#3%AhtjL>?8oD<!I-*eIaX%jTxEW`ru0lAETh?8@W+1@C}w z@%<)-2mjDdTJEORXT<=mePUOI%G3t1N_;1U~@8s{T@aY*0LfLFs2JgE$)?Up}SD?bk5gTpZ>&WKVv<4cqZ?W{_kDk4-NE&LjJcTG57J8>p#1p1y_0_q z+J6FbRZX`#YXaobY8?lx`$o4g%|*DFv;3Px?w+cg#Uo|oE3DqX_6rWf>omfYM{8^P z718MQ=UO04f@Eu(I&wR)bh?F>7o~D0MCXb(Nk3eL0{mKX{>Ol@F?aFL%I}ZYjAKOP zeqxn!vOPwr`s#6Lj!VhP+RDx(O%ZR+NK08|31qQ`ywrI_^8WP?C2%a% zc3%p_%X7BD-|y@$5Kq@nYLidU#Z|b5jqzLgSmB;)@=7#j zF{H_Lg?8@ELxMKBH|FY|q`^v!cio(FH^)dym?-g7{(fCYi+HqQwbppNWrvUK;$D_Qdcif`Zy?)TdQerkmdLM`M3KE7b%e6vW zl`p@#vmv====*Ma93>0hGxNJmIbX4juV2e-+H@WrShKIN&o^!{Zx}p!Lsk2PP|VTV z=)BDEDbSJ$SJcw|+&%SVdtkm^<4=AWvF6P5`n1h?O@W+hbb|qxDNs& zaT+(yhl)^m_vE$s% zD)Q14P}bcK>Zylqcm8#F|NCR7TVm*i3?GQnhWWl8_GZL z3E*{;42UuO_XGd=7yt7{vHy=fvG+AeZ8{Ob@=r`XJw5NOwbj++LR)iijmt`<-qt1P zYAFE%mDTo>)n*j-!(~8WLJd?sw>mpk54pEC!eKK7r;W|4Uu^_b_t!+u58sZ|xI6Fx z+1+rLj<}&i2BpOLmw-Prr0ounM|OpLX*lxjbZPr-x2{`27yHX2;pIzia2pE&2r<|L zx#{*mC4)22`Ol+;6i5=XIRH|cJj9LX!uY!5i%>*BJCpSq%URu$vltIT}Gx!&%(v5yM)~su(T3cD5gF&t3}8 zGjmps7S>Q4#XbHx}Oj;rcn+|~l63>Du56UlToNoiu>I)5OS|Gye z3qYHQ)GY{_ku|@l$RWlLAb<*?(Dt4Xc$ETI7i5+AW0rGMCoV_9^l?!Qdw%04GX!QN zclP8O64Dck6+UXBJThAH_UyO^h_EJ=IKy*jOXRcxa!wz9O3w1^TMjmDk>T(Q!aMl> z>zKCRrrwQxsGVf1`&xrA0O%=UKA+>=@9|AHeFiVn;N+zaf%>yaz(qSFf2EafRPQTN zw0V>$6#z8T;U8i!mdtUB1KGdy3+w?1l#S#4jkRGwx;$A^KKZqQKtA#l&Cn3gVYZ@^ ztD|zN*2|(m^6k-bW(49@wj$xU3w!{m_}2@5ZoLF#wYdQO=)rnEsHlI)swx!7vpn=7 zAUc!Pv3T5^U`kl75wN%kF!tN5O21Xp?%+L|UusV5dub?8k5mi=bQx^9MMpg2G)4fP zm4+Tk!+zN;FTb<_$ehtla{#K&+zF{A)$3il;}=Z;y(TiTY8eY*|Gz5&SpFNhf0XDjktpq;_$cw0b<*f8tM5|1%6RS!3&)O)r)5Bw)z1KtO} znh{pm&xoQGxzv3yEdFW{fr%5$O~^*uu#97ejAq&dxbH7b^meWE>Zbt3J241VZ(JvD zd<2mS0P-dVaajmSX}^A-1bRkHU$*XeJLd5LVKU{tqr~w+&Nk|~$=hCC>D4$yWR&7Y zzHoW|{t!O4u(i?lH)smrmN5|~(H6%4eAcJ@xQm5}Rr}RwOQFbohZLBnGl+S?y$5e1IV$wEzD16b-M8^^t*WScRP2{!L$O<)zZK1sKI!vD< z__|C>5%< zh27dTU*TC8c%uMtv<&&xeC=-Mo+CSC4)%sY9CltJ3z3X8MXX^4pcX5t7x#nNs}*c@ zt)bb`LvjeNr4!~@wmM6Yl`xt-kF@J9^6yKBjMIFFV zc6z;+4v;74y@>4H141D^A#P~)0l{GL|GEkPvPLep7Ih6a1DsYiMFshYCHyqV zu;%tB9PA)-#&xvsk^?4e}HeWIF{uB)?llekHQh44Adz|Xv=|X8nsFJXc*1q zaLT=g@C^bvm#Mt>hcdy@V-w9Ct!Xttgs7FFueXVQ;Q{sAjOwU!Nvfg{i)(<`=l4>y z*eZmvvuhTfT8M=mf8KMe+WjvK8WfRkpJxHcZ<4rd%7is4+g-JKTsh`ug%VKl%92Ms z=MqBneQx8A6KaqS9LOy`8q`>RO zd|)?}!vdkh7BrsvBSc3e)!-7~8mJ;-ZpT!Q{dW5uN?F+_ZaIT9fYg&YV0*Yuiga%T z^WjkD#*KvqBAn;OVnXH{;T7EfGH63>_zg#GncphwHUVUoVE@s4y9GUwVoRXwJcF$bhqh) zCAoHr+Xu}QDo$fnQn>{VUVZq~%^3Cd*%n>OT&BYK_siZbFcAbiAbBXWrdzc?){W5P zM}kmpC#@wd_+das^nz0LxE}%9*dE;IiDt4mDqolcz|%&bm$KaBDECPC9``Rs0bF_MzZXju z^GJn&1$6a=9I{jGd|;z{Rq}ffw|9Vdg6#VInDX;`pJe7SGdnVdNHG(YsNZjVIJaoW z4ZCMuM_S8(8}Gco6k&b|*f_*|0NuDc!{GbO`E2Wf_|%)Ry5{6&Y)D#9s7QIH-!B5P z?@gT3+(88e$8?dj?BAgu;VJqabr4tm@fo^=c%()en6JH5j(&?HMVAD}3@zdb+nGLO^u_lguH99T+_^!y30NcO+6f$jzF z5d>a2+vUfY^X*wY;s#tWam|loDE0<*o=zDjpWpX#XDe0nkXXxaO{ufw-IXpd#OR+s zfoOk4=+~X171DapYvLIGpO_5C( zBBy^EmxUNQGIz2RX@68Ix(AEXbB#T0M6A{fnayg%v)iMWza)Jk8v~S`7Pn@q)Cu<` zO617VByHYigH+{JkvfB$?@BIlFx~dvga%aXQ?pnkjSiju2ds+)qm!-}(jnDf&ZHJ4k*?=-a`U z?h`POE}F8cfNt8p{ZUjv5Q$j;ka;~@VZ!x0}h?Q_xjUJCyDQ+Ql z!nZ2IBr!hQ`7;>PTY+ANsct0d&=|`Z8UU?|5*#dkzQ1{1xbsbMqHiBe-wTB~L2Jz+ zO@(FQi`!2CU7_RVpQzWws-n=bM&6!8-k6ok&=a1x=LxV`IfpdS&}fEXn)n`l7RvMOKZS9_b)BnN^u)oYLN zjY92e3WaB^|NCHa@+cN6HvBZ#Vck|-Tta%6^KcQa0D4EDn@=HG z@L*~=)H##Zo3Tq|)v4I{6c8(1s`v)E8{Wh-1S`=^dqhDo3B=A8^NW4Q4CZ1g_krTh z6Q@8!6T%-Zx`rhL>R{=WsNT0zMkr5Z4DHdP;+}-rMxi{T4`G$^6Mfl7KEsUpCSq4&E=!zAtW_zM)aQS7flcULJrfNFuWlV>xstahPVQ>(A* zhb&6IE<^WeW1;t1h#X{H+h(^3`I^O?kAP75;epr|-_=fZuL$O-iTzD?*xO$jK2yKE zLDG)MIN~Ez%6qoL3NKkgfhYJ7AfJ33XkO65F|1&UBdp|77Xc=2ZhiVx0>fX50h zQ=9Su_tNW|L_-KBKDH8J{fqlU07zCSlTK}-Byyw8kn3nbYuOPs!Qe7uXlKSvPKTX4SylsHAV zk#qt$1>j#uDHhKDLnF@>x{gPabc8D@y(bnvS{cYTNkt5^I`f8jMCS`bqFpqbipZ5P zz`vgmYvhgsXy=1#t=fog_i?DSw-PMbPR;!sa}|gYB94wM98w18D_xNv;%!%q6B}kq z<%4=i;ofeSt;@K03>;7A*|cmm&3_UBDNi&eH~q08q>q{TOnX0Yha%gX7whAB7qi1< zj@^bEM!bUtmMWLJR}2+3$vZxssp~xRm%5AnfKUZ}c0>Mql=hbrbq~}!(6WrJlg>PQk?QS9j#xOAR0w2u$LMaJ9dIls@bbq5&?RDc!5o` zEMgnGbn+Gh$LZabkiGBDct>pR&G}za6~^kn_p|!%_u>P3953oca!XLz?169b8)HdZ z0%i{X{K(cd*yb8b-F8IE1eN7INe|evF7`h8Mh%ssHwZ|SqPO#pErP-%&_R3hj#1xf zA?~3Kfcd*2;lDQQ_e1;Z4-9aL(o)h#Ft@nHv7PTA93ozQWo+m9>NkI0M38eeR-#piphM+JX9(0(w zKb|>ht7y%M_X|pDQHyMS?3>KoHzZ%79n85f9w^0-(vzjIw6}apZ5Pzq`qJpb{53f6 z2BrkYjHeCV2>FAQ23^av=O5Hh9CL>-F=m1opH^k5#@Oa(QF6RB6R3xda)%&C+48Qw zxPd^60HePpCGUd226pK_Gn0#^i9TgE9%gPhmwE~!ZT~6b((_DS=3OTib5>RA5GF

`hvq#W<=o1C3*;_X<2xv2RJgoaTcw@XXLrbG|IGoWWzeQL^t8*bFD zHPVnA5HL9MZV;ryA>e0^ncFSes>#>zM^YoNdm*p1q^`TmS~uO)q13Hba5;*;Vk;J} z&}eH{1(f5-)f|_1u~3+sc~C#${hj)n_s7t`hGD#_vk5l7Xyh_JjRs;JXBReZBag=( z&J5|<@s?IMc>Y-E;Zxd+t?u?7FO2cVHEY%*=bW?E#@H`mG-E(ROF>Uy`%1xzUUtL3 z*^fqqrtxAbI*!eH+$s*?Q?NSu>2Z_;@>)hXo9^`rkAzK;MrQHxy8RxibW`u+*~*Tj8MS~+P8 zz2GBEDyTCN6)-+hobf)Er@DS=7oL5!+TaT1$k`}HG-&4x8?5f(p^qeX8oJt2C6%2i=f%TE9rJ?g|p#{;dF?lK}*;K$9 zb3rsk!qv_{hrsf?yWDHlG9bvc)xNH(>5cv&yiKDeH$jW=n&tBt-$dYZ<^RXVfS7Aq^TS2x4%(?lT3$mEG&oIAGWfyIH6~EFDwoGX#2v#z>sPhbuHgr3 zrr{q#g=#w@#zdq7Trzt-*J)z%@qCs&dAi*v)$2dJ+*#FE-|-%q_>rzrXSbNdp>6c) z`VH4Rg&8$0SPGB=z6<$Vvudr0tbZ)?woGc|%v6)>D2j(5Fc?2Y3=^#%0wEliN0na` zyM?7(E_7n{xW?xR3MKVzudK{F_u+yie}OjjuTFX1XG7&K)#$NRd@6CAq4h6FGL8m2Yf^O2tOlUb54(4O2I6PT z_#=shLn4pu{9nJKL?)@6d!!yZk4`^lyB7pT<5iS<*KK^ozjl$|KWMWs_(AZEUfV~` zRX5?elG9A-9v^}s26td?#(On1E?6`!BitjUd_VNJh7Gsj;x;NMovn*lEeO4cq*jR# z4e}{}Q4?fU)v(RAy`;Y&cm1-ymcxJtl9Rok?MXN`U1;3R`VrgHKigvr#$AH6qE`z~ zwfr6$y{aU3CGI!0=m_~eIA&h(lVa2%u|E{oO-Q z=vw1Uu&jt#mV=s@SRb)=O34ctuS=WfE z=(+s+*PtpCF7Tew`eLeJMLaw_T~SA4-G-|%;N@?vQ{H3J!19qUX+XnkS}`Uzx!3*G z1^OUX{Ok|d96Pv&t|0X zJa15zW&N)4^=3cC|2?dnmzVe0RU7nC=@F>Xo+1!}W@b+l3YhO0yuaNHFgnbslytW+ zj?PCvKC}Ae-2oD*$<>VVeC-`i8pu{Odl|SBu}jO1NJAt&zy3u8mV!4y?lCt=3R_!j z?vFT-+)rynkUAVZXJblssLT5C#m44HanEhv12Fd@jVX@a3BK#whli!F8M|kd>{|1o zM0K?Hu&>*3mf9G4?mQ|CuC)3|c!K6@<)mbO-6Rex6XR(B)ppV&9<(K%gD+NhtE0jj z?!8@hSbA(?s5H#cZNo)4as6S9PJMrt>6fp&2G`3C19=Pcoj+deiPci9D^`5iU6E3( z2n%|UY<-4jxwO4>l3DKU)-$}}K_%QOYJ!r8u-pBiN{e^hh~JBiV_SOF9v{0fJ==Vo zt~I+>Eyo6;s$p2boiEhAFNqVQHcO zyfZnms3Ba%1-UZG>>pQOdLWRn*n%pbs-nVkEd+%x36<2CQJ0iL znEbbktt58ws-9^tVlOFisb&*;k}*juP6x`_(*f1v4Uky$7>`;je<>}?^7?kvD1cJq zEy&esSn;(j=dx}YGsarJo6`P84w7$P2-_t{;(EY9b^T|w!8mR`(bMiS*NY3I_AxmM z(OuIVvt1~#-;ALFg6c1aQwp=;r}4OK6g_DhWBu#)zxN7Ql745|?f$XlGWnaupO+O| z%hNOJGS!PK=RrM>fX$%{tc>~7mWu1L91(R{LJ70Lz|~Vyc&L6sOS^3u9%_Sp+qQpr z^6u#e4VrPKjD5P+3jprH8O$HBB2;z3uoYz30oTD!JlMmad04UvPSl1#PwiJjZ(;;v zqK|HT0yh*hckVw{DLn;_OH+9Ev!$H8< z1_8EY6{?PoT7DqJ-RzWqq_(j)*Ew96J!SyODmFM$d~~jK^!nNLnHK)Tm}mo|a-Q)+ zy+Z4IbF*xw3L#5u3(j8UAS9s4_Kc>r=BNk1ZTHy#g5GG6*wN+f-A2# zsPDy&u$x9K=hn*iAJ9XKJ1Q8J46FXCCnVt%DP@4-^v(P8w*}5LR7zfZnD&! zE7KYO+U_Y>pVrD}1nfm7u{#zaqMZY=Ka7PxL4dSBI_IbG6f9!&#|p%DO|`ufP7rm$ zb3?0#*9FA^hgl;~tWqS&YH}Fev~{%G3&_F531wfFM|kE8>fFId5Frj|VsXehHCyJ= zyqZe=2jUVFdou9IF2{9TV(w!O7W@_fq}<*c<|~;W^FNI6@H`zuuTZJrrg1aP8GGxH z{f(_A_!(4a_Y%Q0(bic>>z}3x^UuH2dM@o_L)kHbN?ic`2L)=$0KrqYd?mmcV^`uo-YWK%U$4J&_~ zV}S#jvOOraRjboqku&?;p>*RH6CcgHvRgM#evJ#SOQ+h=6_poV{Fh#64+LT2_ z?+E)vbUn7-cUO!m5zMOootZcdSv7qT*BGvd35g|Mo+IX(!I2su`G8()x_@GQu{&jL zV71!C2}n5!J`#|ak7hEelH8vha4$EiKi|xwaBxtsD5^!P9sK!ta3XMD^w^`~NK4dK z-DeDk+GqLZ<^XouA#0qz&qt6+`>(Md7D`D1dm#6pQ#B_-A?(%hHJihcx;smd(P+=O z!WuN;!7&R*jr)}E09n=VewztT3fpLv?WlPsy38n-&+=EB34s?kVROSnL)l?Znb_GR zsqm{-+hS5>iA;kZh&g__Im5|)^XvwTRkmf%>gQ<|p76Vylq<>?1HpAwY3r4xf@kZv zgh{?LRTdMGLF-EcG6nF!nw3I7`o%S@$gZ8mDo$#j-!;cwBkHJ$1tufq@YN(v; z4y|9UbFJ0eb1lcL^f$~q(%4{NL+8OQAa%v8BhekTElV|<>T}#)b_!&F$a}FLNb<_0 z`SJF3B~^&=Qyrq(k6kN@Ly9Z_s3r{-zuseEud_>D0jlGQUuZ=hmcOk-=n^ zT-wR`#92}6#E-_f{KUGx2Ul(x|9yt9J;w#;PZ=7cHVU|NSlal&i&qmu64&K&@D+0o z`Lh&{qGlnpvwPe3{H^K!{PNn8F#y?Y>`gd?gMPmxlP3J1L;2qhn%LbZ>8>d6ie3d| z(u*d=Bd%tKA#we2;Ix;gJmoQcnojM)#Flo9ZdXT&Xy&n}V82*w#Gk0-q^_!imPQ70 z)PJBr3=(Lk2~aRYl+t*+YPXMB?Dso$nN;E**h919+M{`Fyq#A=q3$yED?NOA=F30q zvo&QZ*`cL5Q+@|2(I++vdH8ttMuPgla6mXWg^pGRB&HY<6W zXwo6n0p#;FPNit7mU}TKZj6CCVrL>J`ysoaH<-`1Y9XatlQ||BNN5RrM4n z0#=F618bj2BWYfl?NN{Ms7t7&)%gq{S0{2xplmqP%G-QvB_|x3fvc1=i9XKI_uBSn*^!N4j);hEZWyE z1eTSB)m_{=SlVHK7qTZuW@G4mK1LigmuWZEAaO!@XybmU+FHY|i&^vBFQ@o06mhnw zJEWc7P&yQnz!7`kUynxy|3IrE-XlJAra{d^+b} z?yjBf+tTqe87!?Od(W0^LR+cJ*LAn}4BcY}NWLgBKY6+q-*~@MkC$mXCeF}BmeNy< zB~lukt9XP=ab9HFOgor}(HLws@LMp0HPO=vU99^}Qu}(9L2@I<6gWhd0v-vfx6N+& zHkNC9&UB=PR86~%D=F{MZok})r{U6KdayD|b6W{uhJJH(1hoK9Nm?shF za&5yzRa>?5X-en#lX_VyoDjlmSZuKx0<006M#mbLKp6!YASu<9QP5pF=Cn`y2h2LH zT({J``tDXe-|8aefylgYaH_{SPet?axXkv2@9#QBZ@@-NQ3;EVdb9AM{beWSdTLjd z1r|CPdrD*5>sH7hv@5X+DYwOxBLl7CoC9FRD{f_XcW}5M)99 zuND#IZB(7Z{<`jF>=q&>TF#s5XzOBP!I#1*^8a@+X|i+b5?%aK#^H*z2UnU zp!(-vOt@Aa^?>>6IK+2di~^R5i^0-tL%@OT+J7=|_X$(opcCco<4+M}Ur15+9ag?c z`izhn6hvHJi4(1JEvz2rn?e~5b?2zsqZS(x<)fbCwQ2nbD&e!8LMA5}i+k-fvN=1D zSeJn&E_~+i`za9jbW4P(GP(Ga}kc7$I+>eRsr2iO+@i_cT)-M ze-f?6W{OC?5IORzON=x0=!U^t^k8}GL-}!JiR-L+VSJOFX%wc0svPx)h;gs!Ti#WT zYU2xMtLc&kNRE-T2iZHRrYF?zDj^n8LV2|7-ccyu*A~Pi)-xVW#|KHd>9&F5$@3gD z?#SNbyat~m}^fl7pf#YU1 zY?Bf06V0&l?rJ7+bGr(3UcU4S&S-)l4$B2mgmA_i(=?^i7dR1Adx0l2ZsUL_`*Yl( zmRx$HvSTxnKkJpxZ};;t!^p1>%sL4lo-KZ7bgkL&1H=c)jX$W<A!Mj|W1hFiEvatkwIl&VFi&{4AC%dHv;p!3M0V+fzh$97jI+ct6QuV%JK2azs5x zs#$UQ%d;!7#$TY7@c{rZ2>r7jVMTv@FC^2=za(*xOGIjWi{Zh_)`&h8Pqy9W{?wLO z#V?uZ1E+7lv#?mmNU$)r5#mw!feo!(l5t@SHCdHf2_HUJOncIJ%A*;9yT6#xV2Oin z42OmF&jj{BgKwJ+(C@{e_K@3sQ{R13Gp@R3tCC0Q4NeD~?)1E7OR9OHgrwp%>%9R9 zRYi-JD@apn#6kGDZ3m2-F1OhxzYe$OtW-1&*O&NBKgbDY=*fY< z8gMGr)drQPJKAJMX4oSV81wRc3jzcEfuH zoYe;&hlFn$>u*hkNNpRx*-svu$;4OC!Rd;p2LJJ8CoXiU``R*?>tJ*+KNk@w{RPK; zYf6}TWN?r;fbA=BZdpr9z)n6JMtwyey;jrowC@gH#r*H5g$@_z_J6)+5A<^hzp^|z zQt^x4x4%dJBXpXpM7Ae&b0wFmN8`m??;Qk~d;pLwztV{jW5O~;ZL+I-GoeC9RNLXR zcJ6QsfE44+r1Ta#!jX~#uq2A2H7_4V?mDv&XXv=27IKJ(2lI0iP-(Y?ZNX(qEhb&* zdpH?Re;EmkELG*Ez2h_Riy3$+`onL+DTFLp%fJrrqCj-~Y(96hbBFy)DlT4ukO|L0 z@5hVXW-6a$@rs>IuVdJ3L2A&&+56i&)oY6MM4l~SN8dFQoZgbP066&$te<3iN-z`D z>_==X&hS9VLQ5(6k6hxYOGn$u+5SiP$l9jhX>Zuk@<%OK62cO69?i3-vZ=4ebKobq z8;u9kxYU_{R&5F??I=CgvHji&ZtU+c!bX4M_J~jhE77QJ{j4H0dXUnS?OY2#J{N%H z6|Sj~HHRs^;oKfj$#A)j4f;;(PkWP*i5)6>w5Y(W06=9ID=(748~)*v>HQgl?^p_! z=bKI3rqWbg+1S|jm$x_o*i&OMzo9zeIX%vZv-UU}7o0Ye*X!FUKmlHYj?Co@))?b_F1o zxkC3^9rE)~rgz2ksXO0Mh+JQ3w`%oyUI6HJpq*@j+Rb47qPo+1MtO{>>26U$Pea!x zj9(j*V)1%_bC8VUEs-*Lp8AZjq&hj?S-R~ zbAN4Zs^@8mC7|i`W(KTm71KmDPhtk&#^;RyjFIq>-^!AQZe2FU7IY~UjLbMQ_+SpI zSQvPzb{+j8fe~YvhBr>H?~4I5U_(yRvt1S*H2Z#vLee#p3?^W3yRY;&pLqnOOtwYe zjasL62joYNe)Q3cI)?16%uRXE@f`dSR2d=k9SrpC<_0 z#MW%ItYvzP3lv%8o$`tv2Yc;`;(Px3-FtT6R4;J&oX_^~-R4;|SvMV7G7&cRlD>zo zh6q#fB5JMr?EYza%?ygg2kqlNz2cH!6(V4_5L+ZC)z9ldMDM+R;y8M<-mzw*Bw~Qn z17Fm`j}{}?Ukak=w)^d*hB^lkOiKN{H^h8KR|8~MlfnV-N9M~k8~JUR0lj?voYxBK z{ZC9!D~WpoBn+aVpMRp(3b&f2h|KSwHup?Kz%Q(gl}oai+v-o2g<7oL5? z@nnCk-UI2p(LP9Spe@zxHf0yVn9Noz|9i|0B%l8Hg{54DB656fD9&(q)g6~z& z@-i+|xeSH5^9ReA6Y>mCznQc2svNlR_&_J^jx8#M(Hlq6xAOi+gaphyU7{S z9%k!`ElJo_bw#Xm&Yk_q40l(rKjZDXrH~NBQSssAjL8DPxCs|-j-~5 zDYdWZI5M3tyyf%Z9973M6}w!Lpk|f^oe4p7xYkPj=+={ZSUO>>o+mkj8$gOg^)N&f z_TrFqH2fgltUtWrK4Ugg{qbOYKvvGXW+Nxy*_+Y2m_CEY?Dd=G71OCT9`}1hPmFR| z?EU!nSoti2>E`-^dnjyo&t9EI-EdoEs*gY?w2?`BlYJeDYvKq_{eeH_$4AIVckH@0 zT>C^^)z$XLSnLDmTCX^LkHT^DacUZV?EBYbrrBc>lSZDKjZDpl3+-bvMAE(hY_F30}BUDebBP1)tiJMk|Y|B}1Q9gQEk`Boz8?WXe0PX09$s z4RnI{ z`;C*zbG~G=ZLYt$QlY4-Z=-mi&{#9I)wLqjr2|&1Mr>qM|JohDQ8kd&-?hHt+sPW8 zc9oL4go35lme+g1or!=Y3poU~(udI)zKP*4AJNPAaykmD+BeXpweI(AD$e3rB^&|k;Nk)WJsM)_|ib^mlM%K{S~GB<0?@r_Xp;hI}Zcc(zY*(3~Y__(ad8WWEaLhpc$Y zubqC}o+Asp1GuX^F|Lw@xPi}6vxt1dNWu~uu}*{cSOY}lqO&VWa76AibCaFc4wO5+ z+Re_z=VY&A_>+xXS5^Gp`q22^$~fO;#ankG&wy8~-1o>fozGA|6undMua5=?o5DCu zDCVu?Xuiq$c8Grc`7M2*d(DF+;RcLG|_dK#@IL&2D^h;Kb3$C4YAPmCOa=mh`UeUy6-eS?czMb zRo`6uXMMnaeuEFBRk57SU?HA|X|k~wOFk>9K{oPn@A^5$n#tU<1uDU{{=)oV2@>{Y$BC;K2P#$i1#;R8t8 zX4GE6w=xW&|7HsR4N8jTUKX8N<;MC4-$+%q^TX#IvsK7dSTgeAb&^>1&M5R1tZ~F2 zf>%i3T|H|6mBSwVq<}hi@qbWXO6*bQOZ3Lf^hUb~Jl}Z!gmO`Pc}#4hS1Iw+zrFN7 zeiXO~kY?Y3f50H7G{-U!P*hoAyb|jE+kc5^0`ZTCZ6P+=)H z1U;^><_P$$vB52MaW%>PuiuKr072|LG94ty*hXNd?@q*%qBrRrQj`r`V}fo4c|)r1w9lwTG>yDgQ;N`PO^Dd($*rcAYXl= zpu+UO*5}{0!CyaW#J``X1C(+uL$rC%PnqrR|ANXh1lq@hi*W<4%$1doYN3 zZ4BqPAckHAEWeQ@kGqz@7CMq(UMk z1NBd&2ZDhE13ka5CXfF$K_Jxs7iItd_yeQg-?;lD=EgJ4zj^Zex3yJ1h8UQ+`!mY< ze=~Q$CW)mFT-MINP}fbmB@Qj@0$cna_TDqB$+p`TRbEg56{V@5fJ#S-(xikUAksk~ z2q>V4^xkX4#)}G(UP6-=Arz6&n}UFVbV4VH)PyD_1QG)K&bQV%Yn}D#clQ3j|8S|7 zd3bWyIp-L2jG;e$KxI+jGs(R@)fTX6rLDLB{}fS7E`gFoA%a={&vW)4lr(vE@E`y8 zo*4eBaQW<|LTws7Z^Tqueqf2?nj#>~ym5AO^c3A%BscmMpu7nWYO7zp5-TZlR>Kz( z%}h_f(Em56JeXEPFMx&eX^sa94~4r7+RM~j0O}B0I?S=P`LualQ+2tt`SNB-%&Ct% zM<#DfcRRazdM`>NJHLBs;JuZZQ$(A|ftApEt>(cZIqxZSNBPv1hFWBa+l4ORQuy18 z`#=9xr5&uyp|Zu&xrUM9z-e8?JX+5xQzJO&tQME<%T!w72V1pO)hXqv^}9p%FI0h11bK>2dS2)=gQc&8v@RU!4jKZ@hU<=TYTLX%fFj>ce+)tGOU7}UHen6=YmQaxn6tX{G0_v1* z;w;rAR=Z3MC|w< zLQg|Zs?wg!g50v;yswlm$))O^QNWEZRYY=L)mVpyh;a9 zQR}?~+uvwJGAZq5abAC<{-_kGoAOfzn5zJjwUbq#eW!L%UqSRbcTAo@R$xqbnq2vI z!f|P*nZu2CZhxSoW_8AL{D&Th*is}UT-7ie}5VkCINyrMnRShm(t^sy1Dv2*7bc7YOu03W87pS zeasIVXkP`IVn~;PTL{btlt&_4Aiz+iVRQ&(Ccd5?n;d!P#2k+YRc`L0!pg_z)RzCN zs#i6y$0}S82gWb0R96_ckq7?1z@WiJfJ;d24`uaZV1HpG7*|Il-1ED=-fZL7=0Z(` zj4hpvUHzYcmVJNV%$msk)48-^f%+Qxjg`8*kv2?9dce{*M7c9Owb$(g~wMNS_c5D&~P@wmzKYN1}xUK0lu z%Mb_|HaDbpV{uA8d22l8R8`tY>Lgax)KpX%Ukimke zTi@~BUkwO_Q@Kh7ml8N3L1+Z<_ZhE|t085{PX{fF!V=9&>L1p2;&ge+4=7~v0d8MH zGW}G3hz!5$}<7I78P5I{Ygz zl>bZ&1@0f7sM5w-g`a<4%_a`v2dD7+z|3j6>Y0i1SIb2c+=KXP63~s@Ept4*eNX}v zRl^$DGD~s(-kn0oUAduPAe(7P25N}Pz!$h_zj`Pmh1P<=HyCJMHR<)n;1JkPIBV=b zm>o%RCQZ4NwX*Abf2c8RQU=yM==x2LyPQ;O56akmj_C!2``VU1uz~8Nda3NnVfH^L zd5*Y4;v^pFmUqX!_&nt|(Ou=+zeJ9?kTT*@&LL$00K_V%#my8~#4<;E@e~HEI5>{{p>k=GYu)giPx(5gtxOSl+!7>HVOV z)huEh$~}~Xg-jUi3j6KvB@T}10twP+>lY(e_NX4pi55;m+(>!!uLc*7#@8j4g0O<9T|yB^m1j0a4R z?RQL6gWvv9F4(@OKk%d`2T;x`L|N-rG`weT7O(#COb{aUu49W&sI*mUh>R%>V~aH8 zFb0Ztlydb*yF~N0Px(`sz<>JNU;6aS?5!&H61ZAmmJxd2;{6X*Z{hW7prUho{S|&` zyaIJj=Nc;TNMty@UCXOepcF>jF`p~QDc%KcPltQ<1nUWf*GcN>z>o&(820Mqg-52h ztu#_O=LgMIZ_r<1J_9zw&FRE(V~Z-cseMI}b>QzMsjSp$CmC3NF?72>%<|^^AX!xP zj&~67b+)sME5PRNnWoV)ZtXI(XIJt*14`R}^Rq5uujK1N$Sa>1HC=%{(ht#$E+ z?byR71!>p|6?@>$B+HGEdEIk?(1~0jRJVCONxcpT!jhG=d6*5RT*-Yv1*3}syw0}f zrJI;5wz*yxsPlzR---?rc+7!pE4i_wU{xTa^1r@+qGzNrdMfW!rIYy?nxCD8Ki+RJ*x|qhS!d2Se>>o z#L$$mle*C=*Jf@pezO?G{48Q8RhXu9e%lt`)jQu;uFgK6bcg5YEO5OgVC9(Y9y1@g zA?iUs$+n-P;mLv+>IFVRV~iXYL+p&AJY6|$x{M>6)iLP-KO?bmT2>bsy1q869|?S) ze5MMVesj={Spc}MY65;IU5xNCyojQ)CL^&NEFQgJ;gbmD>^KIbJLmzG3g zL0Y4Cko9(Vtc_4y(!GITy z)mXQIFVa~pR5g77>OfT9d=|W(L+>`c^3q% z&e$8{pGr7%u{t+gB|ogr;^(d;eLDKrvE2>fBG=C@oGMY4Tv9(TtE#{0w^$l}R;d`w z`z;wb%o!_v@YJ!ndm*LXA>LpgZRpQdddu^rE~c7SVM zZ}4)m*=rK&dY6cH_S|ymxr4e*Hi7*y5X03A3apDrx<)>s`An4#u98Kgfpl{%s-(74 zS(`Qn`CyVa(e+aYzY+SYQl!)k`llS3W5A-1J@6I|(rd@R`R&aenmFw76vgr$pu-`4 zccJiVrcdOTRHg>Cg{fRGX3th#{(jyL-fD*owLzKZHzIvWQ^b|ALFMJDlCnEbd!A@* zQmw)-Ze~YQni*Z(hm9vZQ=% z=1aWS93kyK{?pL%HD1Z5NT9eIL_R~;Syy6~Z z31;mZ*SOynb-2wa3-RSQF3lLL>Ub?_Z2fxGxW2?GY?!8iJ=3>iyL6+aHK4V_Sv9}7 z2HlY8x-oy%80O+E6R4DeOJhx8cbzZGmW=X&Mze>Lfr^xP7Xl~tC6|>E4SZ(etaE#Z zqQCN{RBF6|ce58Kl+Zn~&CzhC_udS-QF>OiQYXHQ&MDzKKQn)GZ9KmuQyHB>9+@*^ zy&MCztSG$a{k{ecA7S5Y1q#EB4yO8#;Pp3o4c!{|7K#VnME33Jd&VIXQhM&Zo&Z)@ z4Wm38ch`|U9rSvO=UVk>-m0qx%eMFg=5{>SnAin3MMd&)V`j7NvKO3Nqzn&(erGq(=vwZMOY(Oh2)Od1+757@Az1) zC3G0Rq)Z6~l?s^NYh@*k7Wnrri0TTQ{B445Xa$m&`FYEP3fb=TB>CB#Uj^L64qoMq za0ofYw&L@j4h5Qa2A0tsb4Pi_Y4Zf=C6osJVRJKA zz7!Voi=%e0pfPeF0d_!$1m3ih6!icd4I!Ja28Qo1X!(Ewe1%iHViNkim6jxvr0B

cbC|Q|($H+Fx{E=8tif;fSX-Re>eiD_OO#0<{5w!6@t$9F zvo4}9xOYG;rtfF$+WR9M8J_G5ry63KcONw~SE1PuVaK&-H1DP}`^4!^EO+qo^P89W zOF3o~ak=Ioi!WD%KH*y#wS^NVc5t$LRZWy`YvvH(|HVyOkN$dn%LKii)bp@>y2V zSbv4pDX|o73L$U`IgMH#f9N$AHpue8yx$DXZDnRjmMv{1EH&CV7v3tgdN^uRHTOBJ zKqNG#!ddM(N{8YehKE)Dx_77H6yd?-cauz~FU_zR?pLlW%5`q%qaS0 zitD-aGC7jgGLs%^vX8&LlNgU^@xBi2^WZ52rAvD|- z?$EmtyleS4(k?i$j|5^dNN4s|_uNMAbZq60NQ5IopS}Uu!@iwnFnN|ndx_q<)+ajF zHNt-OGz>LH;uV(-3`+IS{+bOg)KT|Sh3lq5^&_7u3^;yUpH}$~YL$J&hhjNCry+lH z4?q3Om#o|zNIP0kzaa(NhSA)`%Q|9i{ejb%a|C*CE1PPNQ#AO;R=ySa%B#CXn&m!R zsv4=CjaHb{IQL)29vGqI#xn(<7R^D=2;c=nx`Yv#*uk3WT*Lg|5iH~(8*8!pG96$KoW~c{Z~7dIRdn`&8it4{y9(Lc^YIZ>*Hd1k zSKmoQV^S3nkC|^(gxU(ab#PMMM%C`JT$uHVP71*5%D85plWjfc#GI<*iD6hD3WVeiU zszNK9@Q$5hH1BU~bm2Ex1Sh-mzF*2FZL&CM1zNJsM#c|GrcZ5meqah6xS?~(CzO3P zAzsuujg+nQuxAWvvZ*`3?gLKL2*qTV7$|PMQD>s#B_mFrxCE5{b{LmBb0lL5zsxo8 zboqouDW*v^x&ve$U($B%tezjpMeBeI=;sJHh=gE039vhq{wqzoSYfTj)qUXkyJ@+l z)z|gJPahf}6E|N~Jg{O;{8f|+yn?46#xgm(#a8a#*jR|s>1tIlH zAR{2VPuP1QX*7puX0}w3yB@D@9qjI6sYMRMg=#UUuhlM#9dhmi|I@_@naj}T?8a04 zGD1eP8ZMo38jpWVvoh5Hg^0rZ99z)VULZNC25*{cKm_>!aAM+Py(P@|ntUq(ie>c^ zi>fT78a125updGCn$&V7UoQlX`ZCLmav z_97Gb5*%K0PXM*B@4?y*Au^`}AQ4m_y@w4PKu_C zC&)&-rsQi7L5Q~?nyo>M)0Y?0dmOyK(M}n&d}Q>kM#c!qaKa(R+Qse>>R@8$>1x4+R2|2j*7V z;LWX!7R6t#XhvVW&|te+t1$OYt@BFH(o`QbozzIRWHLfBej1l6OjHWvcd@y8 zqVDO_Yk$+8J(+xl<;01RJ0_|>9_<93ExmI83+)}lnNFSRXAB>BTc19je|G-Pqbq`U zqOWu{$I5>H-cwIdZZPOgNiD<|uHgY4(Yj&X#WX#n5i^A~fTR^Ng?t|ASI9$u(jrul zgt?abf~#3+C&1*VLBo~|zT7T0DQ7bRrC&AQ1dCDYvG$ zrD2UNJCd)Uf;w>N{jUN_Gt+00%cVqfszr=jf3^l@Ig96q{BqIk1ZGhUPBFGIpXLq? zBaHRGRwIWkrrC>Sf5cvj~7R1L=>`?_1GITMqGE)xJ66Pcs zOH_iDLuCs@9kSWPmC0kh`%zC&C`}_=el0ZAVNjcf4&k!}{c>`?I|DND+FpC`Rlf)BRG7d9)C2_m-~*jGtEC|AjAPOOFUYPj}HpBN}Rv2CR$^${y5u zy1FrN2xet*5-MbBI;It+Ws1cH16D7|Z^*Eo?-wovHLMA^v9AxygK+)|JJj&cqnvbH zChlN|?#PZ~;CRQA3?CgaX7&2@?&%SkdB1KJxZF)88Co+83G&#?j@TQa@T|L*-rdr1 zg~REI{%Lr#aJgc3<32T*#e+#L#g? zmpL75KmF^phlNH#*btJ&ih+dNM_E$}nYbbdz^!~yd5d8Uzm>CIhVOtjSuL zIt;(Ya%2c&NpU@dgM9Q6_fhWl6Fg6k(EN`dt5^G2AXwzVH#I4yV*QTsPn#`@mm(IO zBo(V{ML?5H(w?2`o64yGpfXV+cHl+yj6$0?vlu|QA-4;s^)gf4E1r!xb95y#RcAi% zE4EaUSbDBkw}P$!OreD9k|(?19rps6d#z<8SRod!eZ`GV^N<@4!vc!%J0EUb66xJ+ z3EjWvTT~S7tA>t~nfi#y$DFKiySRMa#-igrXDwAAZov5pCF z8i~L*iIVVp&y*%e_nkCyU0%}`Khk59$K;63=O}H3A-5^R>(Juq2jmi5xiT<>zcrgJ z@jr+7girL-jI`|b-Q}=X2hFzK%>%2WJ2Mm}41JY^`08k9#9ku%TKOjTv|HTA56W(S z`znW*8{1e93T8dhX3FMYI?j}F`}{J1W>m1TmIfw^|1>PWz~B@6lq+2JCi+THm}Oa) zvc%0fvA>}*LZi8&LZc?|7DKQJBSpNT3-HeA=_ zqZWb-l2XZbDwRz%bJ#t(FJ@$FB~i@Ms@VU4uc?%4;tL0=+jD(<#@8>^Sy`gGx%+7~mJbj)tK_k7O^u5M?3E7TyEN@4TJ>FjG7-=XOb&tl_!T+(s|Fz)f>EAvG zhFaC3huUb2k=r#H4k2L)#^Ih$FO9qPPMsT-I%e9RJPOXea6=UlCtx{q%N)^)j`BMg(|TLwx(tON!Uq-~Wr6gaZ82R%Eg z5WlJ7c#?yW6ReJRyjksHc#1B`veG35b}km%#Rh}A-+eIfKhNF&IEJ%)X^K3Y(tRxn zQqFBAvJw(=ub?LlMyrRa+Oo<%UIu%*f)<>-YYV-Z1|JL9S@-_O7XOdG>OMVk6iKh* z9w8PS6g0V#GB_10kDWIe-o^aCkNNHAc%G^(GMw?iPdbnM`Ucz^7hY-J-p?U$xE0y_ z6%?OA8a_`QwtwCk|M~9abSG~5?hu>8163A&KX=>7M9j;C?fv>gZFwj~0ZD9~i*B-(*@bH<&rr zTH1}rznVD7*{6WmJ`X8&5w2VP$Q^z5-;4x-hBnQ@9a{-;!WhA(F-c^Cq>6>=zZvmL7BJ#|Nx6_?xkvho zE*MXF>l~Z(Hu|OI9LxF^567DxlX50VLKtQh*pXc(l=$2xoOHI^N<)3$W zIfU=Ox?Pke{$2SGZ%zF=5;)ouH}z_DEU+(F1wVZrRN4L;LKl1NSj6N&UW?IF$5CZ| zg9gIwc~Vetj*W>sYdqdRh&%q7vCF@CX5S?+zO}RRvWhx%?}Sxz$Kh?Wm-J3~5B_od zj?i4<>zgpjvHSRM9t(!wRREYfuZq$Cb?eU~ca&CM=jl^p`v>B;-~5|jcmkacc41{5 z@5vK?+A-AMv2^Vyjefds!+Fs^%{}#Z^!b5rJiZny!Sj!R?Y}LWJfkmI?`#hm^j?!4yRi;R4a8;Z!||yw@@kx`lk}oDz5rPZqbysm#T)Av zm{+dQP|e)@tv@~18q{K@ZWD3 zUvTRbvP3(bU}d}cR#^tTj`AvuVBb6&j zbkqccBx20+Uo-jN|D?@g^U~NcUP|gL4Fm#V>35w9&F~v{Tzkdo_Mz_J*RPLtz)YHA zJBoM9eaQwOXh(lQ(4um3(1xWtNn*aFH||%jUR8n!8OS&iwylb&WGM<|9Y$OqdR?U; zA@O55zd>09eXw>BRJxKgzM znpP^muV}o^F&H3)%oXh(>2zATL1lBKm6&4nRu2HbL8o4(V*Fh?K4PTjq}m7o2B(#W z@7gU-%@y>Pb3ISC5A&)A^_Tsthnw-eTcM7_lX&U(T>m_P`gFtf>kcP$-{?Cao*E0> z=W83?pb-!3(3w1~u<~Q^eftMon^%n9x-1Edu0a)GD^*|epX{e5rEA^pVddn3q(+nK z;wzM4%N;;E*|rVY>#|`EoL0L^`m`68m#vxVqvI4sd!-p^#jeA)6hu zBudjESvhm3k=W2X1;}$@la{U(3H~9iB33higvCwlg&la`M=&^qkSC`7QW2BWgcAQi z@6fd{P&9;#WXAt0G;8Q27H2&P0eS0IFac$Ksn@GkKj>N(DPe;X`|B8T`B#MU{=@rY z5~}MrCji(HTDKmus6eUMBf<{-iHPZa{%eVW`{C4*L=$LXDx8mo?A*)D@13CTeu1W{ zMnbV0$cr~|faUjs>8`2B&`ra4TE2t}!;9wAO-T(ac54dLd-{Z}Gcxaq6&-4sQ2>Hi zJ*X?(q!PPyg6yF&>pElJjP2dm|2fOxe2+Q-t^{>>Z63Yu&K+NC>r5v7?`d*_juqN5 z?N1#EZ=an{y6+WfUG@o=kEo>zQkSOQTrMh~1c%1PcYn16zZfqWpM$+c1v(EjgZfq| zHIR08^8K6UNX3FuD9MLA>u#Ph1!rIzbm}hwK$@FMuIF^G@6T*nb#>ngF1EP)?gvtt zQlM#{buyzGmV=3@D9jIu3UfWfy+xG?L&x@gE$K(@{*=99{osfYaAR&p(;jO zm}EAWe&I|D1e7(;l}bUH05I%+=QTPnm$>kf8oI`76fnN|WIzR4Tg>;ocL~yIq$*5j z<>YEElHJUv{!jrNqeT<2LrVHHufo#ad~883n;k>ym@obE(&)lQ-87}gzF{+=bUT<1 z!RkW-$05GgnZi=?=+h^eVNl2$Js!yVRf;J=ZuYI@B%q18w@MOW`?K5>dkt0IZfEdq z3E|hf37~ZCrwJIC$)sAh;PnQ7jN0@F#>{$ZVu|BJ*m(~zQmBx(o9(e4`hQN4L?kUg znX=&5U;=^wLZ?KDmcYNLf7HTb@A9JZVNE&D?O}DccZMHTWUDG}-I8sBfsIBbi(!vz znwY_t$eT$`X&BwEO&`g9%V|>Ly20$Q?Uaw&166o$=$f|@g$$@!$(rBzPU)j!(5>9c z9};*f&9j<&?6UPxQD|U`q=Cswf}R@7>@+{3;y8|G&_Fy3-%yEo_SbMJl*us|kakcg zkD*>-BN0LbrRfoCZbqkcV4KbRS6PR~*Kb7yU#))p1+z zS6jFbJ|2cRoDSMgy8!$3oZBam`|^4B=^hLbtP3+j-)j-NFKAq<9o=YARObGiz<~#~ z@RNDDAwq9jb+tk$z;4#$o$=N{#o(e!rgZ{_I82ktaDUA6>U7eLg$04nzmI9-dHbyI zDoC-g?2hEX+waN~rF!c$o5wK1nThVW%2Kr44n(4*C1(+;Pe#uQww!{WB!?szQ3AIX zUbeX6fLn+Ip+#;nZT7U%uFc_xQb1i3@jV%YPMP{RGCR;*w!GmaknR(!0PetjyZ{7S zT;>1B_8WylQTm*Vkp)~9H(y2LeBBMWez_@Mkou2AOScqxcSx;1g63Q$j=zycG$IPU&Q!qY($> z!RzoSGDv$@QBHZU570tSpCIyZA#HCjDD~yFQ;Gp`5SqKzz*Qq_-*eI0+Epa^z3B}v z!*}gz*8&RKyUcks6QBT6B_@=Hv^rwCC3_WT|1u1!e+a62{1$=8RS+#)D1Uti75*={ z1WRo6zwf{EO|dvkVY^Qag=)*!Lo9&9+I=@GQ;Eosj57)#xYZ6bw2c>{rAO`$675k9 zz|HwRURrNi?WnS=MR;Css;fmouI93kpv#EGQAx%|oM-UP)G+aGrbh8C@)xGCAB#Yo zeXlY8x-5b*_@7~OmMIM*x8=nt$rVqF@Z2VB#!}cqr)|`ifw*Ybw}r}noRF#ny-IG& zl7TQqy+!9z>7YXip-glLO^$!>W&+YgKfGi6XoZb_Pl4HUWykr!@p7LD6sUG>&CEPp z-G34|Zhr|Coj|{AsZfkvK32yikk5$+e4s_jsU@^Z3>J~2C%Z6W>E7U}R?*%r^dox* z6lNq!-twgd;R{R!bVO>+e%yg8FuK>bPn>8kVB-#&OFaNgpBkrr3lW{|5}x4wtYCDgyw1S`XVP&y?F+GIF%&&(YIo&jM2p8~34LnhvBATi=```_MgfF+QStQ~&}M zKl$vQL_3tBU|+|YbQiT?_I>H>vmp0=s(Zq7Ci9W^7G-VW(Bm%?p&`KcZRe-}-4B;? zjD8T)-H^E1n#BxG1F?C<5T?XX_Lf&{tfn^VaI=%Lj6WUdMgU)%{mI_!9%x7CEvIpg zqD^Bx^T{U8_XZGEXlm9A+mZWNpW8sAVUdvAzKGk(Nzku7=49s0C>v#lzzhChpWYd@ zWkNQ2FqV3P3+n>7u)h4}!s=rPmtJi8e5i?Mzc&zSqyRlMZ^v`Z=FG&@DC}5i#=;WpgWc}L^LU*dU9ryCfMLG`F zr-nA#cFKeTgFUf|aYqo~Y>mj=k9smI^l5~lEVmmvcrOFXX6IerzGrS#PNYuh7q*6A z5y!zCp4p=UmQndC_n;ErX-c$K7qs3Se^C@oC%LNVd^pw{HViw)<}*Nq!U~%}h(~g6 z0M=%^hiZ3RM~S{5-s*Mo(zR-!9&$2Ic+!4Xw&Ur`0y)C5qXkMrT_#N6Dt>kPs{NqPfsgqsYxKS!e#CFiOcR;4$>Yep(Wo(+Y*Gm(W z#RI-DhP=~Wm+P!^w0lFN4{S(sV5M*_U*yw1euT?UG|g=UR}Ipp7lahcy=-qk>50T$ zJCiE~Vb=R12YI5uYVoeE(;1W4R^oqo%$Bec69%}m4=M~qO4~PuKIgF+QE42~f}TT0 znl&u?H@}pAX5g-}Cv;*XP~F>tEl{_elrDVLu)Aut*Y~!PBiURvA8at{?xYobSvZ56 zRmeuGQb0jgE>r|*T!gy!a8?4;&eU>!I|yk zEwUc`WyA1liK4>LBY2**(f-@#RfuMgz0p8^R%9_3J@;0k$qoQHbhX8#^+e3BjpinZ zSV%usrKjtTXJ^{ZJ@xsShedks9tul!zcz~#JdwqA2XC-tW~lw8xKBj{fySK;u?9@$ z(5w#9z}0>bc}pl1uT|i6j|v1AWkB=|P>HcJ#W(im1e9@yQjLI3`V9o(o?!_?AZ!%% z2kaO=QxR#sFS3>?d2E`FBG#{c3RUieP#YR%wUl;|>X05!P<6-nUwBDZL36Fq6&OG^ z_FRs*_;dB=-l|_oRt8NLX)r&*%%T-}Bp%zb4VbdirMJ-Q&e#NVyD;nh&%4L}d&=}5 zRfQ#h@G^=Zn8IxDDU)S8SBfn zB3FTtI_3b~{IxBcP09kp>ZUc^X~v_Caza1Tu-m?;G6u@N)l-3s=}O;xfmM=Ooax$M z+`CW7+eqcr!4-%GhDJ}((8gc_(AncWQH%ua<>I!1t4G~yarxhkMz2!Y(T$$-2Oxfb ztHU^L>@#MU6xC(MW0<(%`bMiBNjI0M1AM@^Pg-b+8Yh#NJ*YJ2pq&e%v*e$gbKL*DpFnYl-w>1}X@3q% z)6_nMeZStGdrR=hBh{yX#?jJ4b1~>e3fG1&$cgr984A%Yu(LMscY|cNl_#~wKG=-G z_S<2(J(8j=G?1X)1Q|<0?yPLnUibK(zm20i%D|^c6ESu#>|iTXp+P_s@r;vUQ|MiM zC9W)Je6CmzmGN_3Zy;yq@j`? zHjjRMX>Pa6_J#i7kJ}~JCWexpMFg2VXjnf|Udb^fVpB_HojX%1N<)j~Wh+oe(&!AW zw^B!TOF(Zz4&L^N@@5RYno4IzE|Y@*KmR6Os+52wy=`QSfRNgIv+R6JWaX!zR?nU&? zpC`Cq81dYeCHmT0BWOakPyz8s9*`Gr^Ial!Ka=w-p>PGS%;+m_sKZXpm6`&7fC_-H zE!B=XAwTL%!u>}d0y`NwKslHKxyAb7Pw%1NSNZzssd6XjCeRF;;mmZg+dU%1EB(Ud z0l~w4fVqM*+xi9-sh1++29e|hJ^5i_>EQ3#m@xp!Qx=1Ok<^VPTcDLgR^;Z zIM2UXYJ~cBBMm~DgYo@=LBu2_`#0>Og*`aPdLY~q49LC&8<0l$E;$8~AvgDYxh3yI z;A>>kR7xsZ0W;qiGB%wQiOzQa=PJ(P3isQEFD+k?^^FCPN&Lz!ClLmb2^T zO>o_#;EQ+P2~Dzo>&qGNk+haACH#GZPTFVILgbFVJdQUBnfbB%$tBR+KXE*O_2NK& zneb??cSDnw>YFQwXKV}>4ig~YZ#Ti<>bbXj5`1jY*|22AB{{YssvI5`-L z|2n9&QwEo#OT%vG%kEuc{Lo=|^b(z_lWQsp8)}Eb`>e9M3|!q~y{A3? z-ek+r=;&74!;tkayw{0PmJEeq>jT5E-1|R{Qx-MD3Xy@1n8at$8-vgtVr$!Hu74i6 zDmL{hvQtT1%H-kP?GVsebkQ*v6%(_9H{W}h5#8b)CG?*_uNnEjfZj&$`xaGv$)PcU zBZYDC?v9mau$i)+RnG?xGdf?)uS^b@Xu{-2tYlv(1O{*SYf>0ucY`CxUG;hWxSakf5(x?i|gSFKWeR(r=!_xidZ zFlAd3z0t1jc4z}_k=VmsKg5exif8%kaVj4XqcK}0H){3#1bkIdml00MdJg-vSyY@A z(A_fLCGI4ndX&~eAWQtIo0I=me%5UP7b>V)pIufgrlOR}4$H*=<@Pdh%{S<5GEfLsjHwGFRd2Dv*u(6d$-0PBoE^??Va$N@^ zGxVDS%i^-3h|0k0>wTKFASm~;XoiUkAnHFaN4D%t|)5n4{(4Pv@%_gKG z!=L=N?(0yi67$(8=93=x%}b9#v9)vVGAMY~YWx88+QP;~t#ugbyS>t&sIh&pW$J^(wjLxY7YSGp$;s9f#S+>Cl-Y>LBE-bGb%Q*j2=yXURE|V`f*#e zH$-U92ntHr_oO|O7L5LV)%FCB4NZgJXK-?`eq+J)7`H7zbBiz!{{Z&_aHLu#hFl)l zxga#S`@!*YDHy2wyYc@9Z3J8A4lB=(_;0r!A?eR_tf58P0)9Je03&{UbdAVghYl7# ziu7x`^+2le228!v1@%VksIkUBSKhy39}6+V1&~u3tGf&>P|OZY8qO- zneW#S_LlKCTbKGMhgT{JSfp$Fb?KJIF;`{}r^;Wv4ow*AN(&06Gm~~s_vjx!sKRsR zgRgZpG^~#`LXQgD@ZL*xEmn!)j*E$j89p72{R7YwE_P1P%ewL#1uJInio&sYIH9`w zACB%1q;%0N@~2v5PHZXW?MnU93clD3&E2^Okl^^~*RifV?-n(4 zp%-ZMT|F*ep!sK2j0#KnB&ZaVKUJ(PGl&?Zl-zPH?*PMA&N#5z(m{UyaNb3{>etPV z{>|gZj!pLU8Qwkn2iP5aA#L`1N)mHE)=;-`Bgk>$~ za1~V3q!uMFPxL{p)On$ZKWvpFG~LX*tD?OE$5IE@re&NjYOV4|T`sujSOK1ck;zs+ z&bZXxd?0m-ym1e(0xTOt0+08;eSh^2INPfDuVqAHwRdS)57W?E?>NL#!Tr!&qFE7a z$W1|`CwJ*b;bWlS(vm~q$0XR@`2Ab;>C+I!FM3%k_?OkW&n!K9>c_!tD`WU1#_)v5hvaf~T`{G;va>OE8% zH5>+k8UDG!xXA9~yf)6JS=$^i2mBm9LQ z<}S_d^LpC7!h0g3BoH_mTr@}nXIfCDxph89frMC2Y7u zn3`Sg5-2c9AKW~eHSU{pXwr1o;r^bsM91-jY-UJ9C`p!dNP?4FY>a(YdJ`%>6(>x! z^_uZlg^Y;JI_!N#3|&5on!tu zO?Q>+mzK5;9y;$*E^}>(^*~Uln?@(m?F%mo>DO$VZuz5gz_nCS=65f&N$bPiw$>Uy zA}m_*Mg=z_D;+Q(Cg~R8S#*+vj%e5(?X-<8q)TRX*;M7Xmgpq<7N=F1(aRM4J>`6A zeK2~}cE$W|O)k2u;wIt_*cL1W(+@+JyCyq%wEGX5H!GqE$`pi8CB2DnHP>^-C3kx zir_Ig(jF#ZB2&bYAE7FcXZb_OguZL}9&EU2HLf$lQLA0cH?A}0cXS~Ms0z_zd!%y} z0QBh!pmT6)%6}SCG65iIECrx>RsV`$>pR|gfNDb>fTD)G(-o@t*7B^U*_H0Dr#;WF zIatc6SxPxubr|_qLDHeGIL_BPXqq+iu%}eiK-3KS3PRF9T83HNuKvQ-Lvi-RswC0H zqDwDMKnD9aG;sZ)#`EwF#+4Qyg<)*8=7hFVr=?hpOhNG(a6B>Ckv>?e9L_$1H|jX4 zE8k$RF5|wa08fG^Re0gm+DYXyCbHKuOpUq!p>XqVS}aPP3?FBWqT&z%3~5wXO7 zbjnPtu4>?91UIOFtmVe(7s^1Wk{(o~m)f2V*_(@71F5qQV9}!#W?ryN-sW^Q5tTJR zeQWtwwfU9^nPl-X&wTdOMDg3HnmzB}NddpqpZer^!{i5$3>n{T$@^=&!bjgbX_h@F zHswvPl1NIjtRUm^Ho2A6bv)`(0`yo)y!;NSbOmK$@1xJ4sPR&hlOejj^18*>ai*=7 z*)l#6`{L7z0#V^;Be0Zn*e^;OsVJW`50AIl+jwu6z_jI4BwDa8k!%MDaCiYsS;%UI z`GpA)Q5T)Xz{=api@4{%xiLRKKHEo0f=)R!g3cGzm;7$o>B&$~VD}Xd9_uQE_mctq z^y9Sr5>J)K%q?PpL1-tC1hI4LjPLAB=$HfjiJcAGVb4rux*PdP_s4(M`8Sn}P!@*I z`nO-5HDkt6Zew~q2HVcjhCuZvNvoK5PTLAWncg7^jWteZI%xD%Yn(Dj^aEzmY6=y~ zs{Yn*(3)z%x~4Mp`zC@WU>3+f@- zF7?8Xr@Ocb+uIdqRM^}3xVg4(HQz`~s(@F;nI01>D<{@` zHn?AL5j16uOnHoav^-m^%^=$#@6tA;-6^TloYuMVcIoQy^kTNa9XVpiwolX8V7jH< ze5Y{C_jL0b_gbk=&TYmr=&@JX%ju0#-KfctiwJf-2Xs)t#XM-I*^Zy0MSzsGqt>3I zU5ih&fIg_ieH|P0);~)5$V=xjFo1CZw+j>86nc2o2EJ;XA+v0-NGcDRH&6MP7F}VU zu`_&|F9PQEmk@D zb_FyYHz}8iSRiFr9&7+A&964LwRaCR&9HirG)9Zu7d5i7S7l+AGtm_DYpL_zE16}p zsQk9S0{e+x-`N#L(p(wg3GB*MUgm-3zRjeIRr)-n>axpy9N`wqN6h!k135yI-2omn z_VSRR0Vvhy63Y$?ujn#%2|*5!@`+DuZGM+h_Q}!6f?h}8kBH;Ygd;~J*R<4CO|(=I zPEUM1zFm%}2E3KhwTbBiKj}alo6oBN4#U{Q(Wrn5`R8{u3dCtxlQPp`zxD^f+?Z7w z&Ekmjx*3w}u0-ueFY#m@VjV^Mv=WOR!HKW$L{ajq&D>4sWi-mvs}x_G3Ykd4FkJq- zSmUYM{z3s)`~7JK;@9$oA92`o(&fy`n}OEkA12%`19hp50XZL9g*E!<6 zbdCq@Pum)x2t~@%xra!vCSxMx`JUiAR@yiy`^93`OB((LsKuF33%PIg1Y3@Jjn#JoA=e$x3IOlGuMYid#q`XO&MpFcKl}J73<#AVmf}5S_>U zS-;DHE+W(y{t6VEk%7`)uJuDS(3RPRy6S72omm<(Ht?nD{sP_=K^2t)R~n~)Hcb!E zUX!`+Bw$(#gcGJN*r&}ba|0xqD&0g_PYNR8kW*Bggb27M&BtS~8`1u|?c0EUR!i+7 zBsNfQIx5fr>{HsOCCQ+J5I+?0OkX?{r!$4JA_z~r?ZTt3>xz)8x0gAYUd2OxWHoe- ziV)eFe0tuIahC@t(MvhrOI*fN-CUmgs+XRSKd1#s{Fdrdh%m)`VQ){ z?8Vl2_V4~{@o)ogi4Sr;3BNa1#MpCC znp^FMkNO~0MI%!?@4V}{6qb~BcEYO2oTM}wqq>X6mvrs!l)>Uk2@XF5Nz83dMT(~e zT2$h^X20+|^VF2~lgpt!JC5_BnUt?5Xdixyyl|x9${B{o6vNzk^V(JCigSlXjLW!t zdk<5`2Lt!Tu?C9DtMwT{4=??e{%mpe*H;#U-DsfvHCQV@Nfj=lHpf2KsQjdL3I{Qy z76!rE&yKBh51HizYrWnSYTjBJKkicA9by#xJyXA=`X@tXC6GMYyFm#mGeCpbY2*@U zqFtkc0I<1f(5;6bT(4Q=Lbl_CPUM>!F~~R$LarCL1uZH}<9h#BVc#9qWV(GVC@LU= z3L+v!Q7I8nkX{|7gsPO#1VjiDdau${tQ08$rEBOUbO;>=geX-*FQH0r5=;WY!0*k> zo%`LjCi3wgS;~xc&stflaW^_1%M_ zD(D=RXwy(3vH3G_`P10cg@4Cn@2=6;nw;K`d7W|t{UcpRi6Z>YmW~JK7YJ09D3j&A z$}8zWMMmZOLV8e%tIG(w+cuvd%iq4!XaMQ6U35^c9AcJcY7O zgV#t@T?;*m)kZcp#{)RDvqtUB*&VN440&LdfMgv*V{zmP52-S~t!=dW`Hp)gv(bB@ z-mLfb@9n;PZ`KI0b*i`R;zH>8o~mhy7ePT4Q9dHmeY6+Rl=mws*L!oGHtu% zIKWJ=v8ftgocuL0s+%an$(!r*K37L_Ij0g#&I5Aed(6?JcT`o*o!x%LDl96lqc4B5 z52;Jp#vE1?$(a&c6c@PP&^)KHQJai4|9p7g+AU*Z$9sYoaP1yhGWaFKLLJ? zhsv*MRva}~nM_K^{lMAyBjQzCjy|gu_6(c~fEKzu2j!gx73);gstBsY^X8}7uRkqAAYumc1cnoQ z-K0e=mF(x*)IY)E0rs~Pqi>l^q4>s~BU)u-maVvGc#h}=G`*TUYoF28Yur^F^Gn0h zg>HmvmO-7&eJ!(D@;1seo5gGyyj8L}NJfKJK`xV}yti}x_&9=N;wPqoPS0b;$C?1e zD>7U=a2yxGX|-sgAS?+JGlNNo20cz6{i>9gCEvk}{$Vu$l(bLV@7SXJv06v~2ftjE zlpEs_bylZjhXa{-Pw`4g+&#nuvSWBHd6c5LFp2t!TeQ@s+&i(f&8qA3$cn2-xgcR> zg@m_rzi`Z$Vq+?pKGe1KY8n0B%Tav{zy&^NrM#yndp_T9O*E1% z)WLRKb`n4)EtnFFdOF%M+1?;T1AZ3T@|XuB<0O!t-mh1GVu2PWHp{RY(zBiFX$Wvw zx&44<Cr)Wxn4T=OV_Dqv*Xpw; z#k4f=SnOzT9{AR9D%YnSOEL7lE}k(j+fv~|mv3E3stkhUdlQx>Bj+)I0o#a3(V{d^W8 zv)2mZc^{Mo+Adh>vXGfoG+-D=FOiratiVdL;kF6%PtDaaO`n~ihrhUUwpfGZNV3>l z!r`}Y&Xd=!ajKl`HP%=la#o$<1x@brYp8Toc04Da6|*`oVmu%yk^qwLr|E9M-R>^HPoP znZ>FfjE4%L;9ArK7DrV|a%~_$tf{nj!Y=Ur9wp*C>#LIX=K}jDs3a61lx~UwFJyUy zZ|ny~1i8mvQtEN3a>b)|1QBrEQQy&m-i_J)RUl1852fsmn-@mUuc9rREc~MK8Wtz! zavLQV6Ils&K>2~=o4fklqU;LGZ<1qNJq9P(FXKtNJ}NiK%n;+KV&q*jgAy2&+`&}K z=V;;Y35rvM z$rs}>C!={$oJWJ6Y*Lg1Er-R5H+)i>@`iY-G-4E{XKn#moFvHCGyXbpl|1uTqj*}?+ksev8Dt>0E^%hNrm&Inon*uVV+POJR#qfMi~3B?9CVI zJ4c8#AV%Rs{0VQlRXVgbSEu5nxW@Ha=51>3xglPBiw$mspW_uw19eyVn`!sfxn+e2 z_-sYHl_dyF-83uuemU($V;78$kHXsf!yVsiQ*Y(Z%lk%mp~}qK=KXaQDl`e8FLR}| zu(QScbPP#Uf)8K)y&sDRK1yCj7sz@?%MVEXwpHU#Q_IX$rhtsoAadERvTO-Zobd@v zir-^jy~jN|XdN2NH=KXAXzKyszqU4B$*)(>j)Mi$zVFYtfcWh$-1R{pwuG#B5emcsSj@P%Y5u)N7V011fI)O({ze;*URca#v%6QYwT$XTkN(G{A#{(YrGppS$nqBct~g zdR9Heqalh)KZaih&5T?^%k4h_xgIMPEbN*z@0b(No1F$!fl9kOiDm(ViSX|7 zh1kG=1fjm0bitWhN6a1JogHX#-FiN4^q00RQKf5-Fd`|CiDeF)#hBX4JsMn%MQJ*m z#{0Y=;lT0KJ%AE$v+`jdQvnbnu`fFUvU*}hjUy4AO^s4dE_J8hJ-ntu0m9@O4887L z&+YJ>af_d6g~-4Tv(_Y&sP8w1$a~VsZV2Q+G~CuY8i3Cd3vVeg?a!Sf}!zPe>D?2)HHWA-}P+z^5@6zWgtVRBgsZ0^~K5k zkY5u{^*SgrMA=OasPV~|t4vtQq@HnKeqK0!#p^`qA?y=Hk$AYE2nf&$ekHkTv15bt zw=xK(S#*8P=-!+C&LkHuMj+cCHI~=Sc|VI1Xtry871;-oG&{9cUQ{?YqKw3NJOX-C zmLpgCiHQCf)pK^j@>BH$CpdFxB(~iaip5L$!b?5PHeP6@s&hVoz7(`bQKfVtNM?ve@JbxgUz z$46Z+M?BR@z7?%%m~9%U+QDxgU|j(Z9{F{(Gd--&t|l(tj+ZPU$nZQaCn?w2v^_*9 zZnU>Ud)Wd8!QkUZD;0^qGiL7bevSuaU*=BS%=iDrTPSsEeJi09uhwDCL!4v${Mb+eu(3n8tgseGEEJSX8nU*yux#<%CeD^^YuD9w=y=}rd$)S9cN;8Z`0 zNllyAwOMVmtf+eE?3_2RzNAQ}b|>01fM{a@!vu_eI6;F>psMy@xj&!Ukm;`T2c4yf z<)j3fVGDmuv}4upa*47Ge8jC3K=Q18eeb?bx<2JlXhu&|7Z4hD{I2c#Q0FfVx_pb za=^2!#ESqBC&3Q0O;t6s#(!MnE@D)?LQ&?Sp+ zc$iDlfS&m-E>5pG-=gT}k*;#2IP9D6>jaY##W~dd$Hfa@o`(jECWmR?N}}=7S*I5_ zHv9m6%mS*0li)yoZLmabdTmi{Bhe86(X)^Qg?}4w_I^-$P2ZS zZ%c{TEgNMVTb-xwE{_Qc&g*16C(Gb4tNKJB#Oh|i3On?~u8?^W{@<25MBhUo5O#uO zw&uIH3RBOD-Cy)ZdRO(FzWWvi2mk?Snp0lTquOZb#O)O2wwk%yJ;7v4Lj+vm>3Z#0 ztwm@C29#xE|(Ua+fK>mhq z7KHvc`|fb57wf;+uRGCj;EHrO^fwVf7iz+)uR^H+QjOj5R5`D4q*?j+&!xV?ojHP* zHk9!MD{CFJGZ8?_gL<0E!G!~Wep z{CJ$*f z$S~aoml0OYhS?IbI>b!@lA%Ovl~d@eI>00c>bdoYb*Zg?p`RuI?u6mprzj-`cB5o@ zp90nsMF6dM01m6&A4D2EN+Q~HpMp}kc|BZ{gxJc~5*ya{UCpQWJbO#(_DfHp?aYGA zE4%X?kG!+j@pF9|ptKGbU-wa{FY84~7vhgMOgIPzjm?luMU{FRV{12woz9>MUzrTR zan2V`&eW`E--o=-iLF}2WPK|&RcIe}2pT*|i#rnjn4U>*G1j?O9AS$}uvyWe!#O!Y zO2C(1Cv~My%%)pP&pg#DRHutoni%Gz_~e+BYw$FY%=ANgLGIP*N=cG zO3mI2^f;w7=&~-Nv|ckO5K?KV*ksYOHI8xaf2j5IW`kMZ$Bo>NdMwVUb54g_5so?l z)#${tzImp_V;@&DwGl4QRm`C=qD5Rpj$WtvfbxZbuF^i~e~;MPE1A2ZO!6a2m+1A) z%-7(LZe3los1)A2A!zO+$);rGwlno|5?!$2uX4tx__}hmL~pKoc+Qd`LgOmLYO?=s zU-u7~dJ2oKXPoiXsheNK90YYRdzhxK(ot45L^y|&hOJWtN@8Ki!D70e_QtHL!1OG} zHbsZzGy1JI@`mnDN&)O!+9r$wACLik912z|_I>E2nrRX~88`F1o9ukE5O;L=s--bN zcwCS3ZGZN2&b592N79Jv_Tm1>Qr~v?!-Rh?Z`B3HXCxnax1W*K1N2Y^Epw&ySL?-G zljZgm!nyX93g3n@ab&=^xte6XX-9|$NYcv@XYbjM4chw$bU3RxC(#-PKW68pAO2Hea;vuI^Z)FvUq@!^L4 zz-4KJ+!2a&9YuX!nQP^=dvc^nW$S#Fc(CMCNg@Kza-^{imSeZ~3ZO%+E;1ecDpuk3R0YO?1f@LjMu~sdcO%3f#CC&bKo8 zRnYOmpbD)m;I9GYIWj}i6Li#BlZbwm99l=vZqCOQ9iQbA%;bymJx;Wv_*b@WxBnUY z8$RLje&M%1Diu9fwNZoXvml=`9;~SaL+a?2lCKwsiVB~PtY<_a`DC8lvn1Sumi>FC zQfZfws)NDZzUg;zqw7X;&a7b}bh-AneIbvShm*4V=w+qX?HB!$)g(_i(3DjO|8nhX z*Y2CN`R8gu);EB$4-P(O`6MjF=y-+EgY9tL?Xb7cXj6mZ^}>Qatc8LZWh?ik9zJ8F zC(iycB~16?GL1cpYO4ovz`UITOQeC7Sb*h7#_R2U8Hr&D&;1gLdVlfXn+?@u-Z5dg z-L-TszdaqmyX;BX9gPj6Y_s{#%>K@x-bG=!m9=p$?~TvEFE%PO5+u=l0`&Uvc)Wx# z?I(MeMepn87pGSY%28|12Z?!+bg?0+#}$1hD<8Gfv^hUenD!M%d#QJ6_5nbD_LsNH z!be*8Ku`4JXW}FnY2RkiJ^V+b%M1O8>0;`^RJUY$`Yp`TEe^Pd-^^@+7Z$o`#;l1!IK5gZVr~(5T=q5Es zmA}Wo6ao|Md3s~$Y~F&o%mw!XB_vs!t*c-%I=TDI=Up{{>jU*ps-3h=c{g2kA}D>1 zps5W#UNPq5v=U~>HB}Mbe21}OEGhWog+<`prhoAz|Ke^wz%&1>>STX2o+@roWjT+L zrTw+Pd7j}P>D3-yQtLR&qk+P^oN+IZx{Ig92>E~47&QdEWRMl7dIasU`TKReBY`A8 zy2j`P`|lo;dVdG~@I7Dd#B_0435hr-Q5nclJyK_x4iK6;L=R1Ndzt*FbyW2tkhw-w3;jkX?LYQs14!NMoYOSwng1(a^i~yfG<9@3;8{eL z@PXH3XIsoN)eH>}7%(541_XswBOcOHE7u=Q~N_))Uw_ zs*h+-k-yivswBhfPs*=9870fj8_?^a-szb0=>q_)WUvGG|K~pDs{*1BgZZiDHAF&~ z;rlcB1BtpuK@=sR-@UTn?Qd4ZKjRzrknilVZ<(Uf*q$@*zJ?j{W|~6*v~1m%2>m@_ z8u+p5#c=b|81vFIK8d+HA8ri@DSn{K-5y?kPnFM@1#*USZm((aZU6f&nX^aOx9+YY z79skP*$#YWb7LY?>?dzBUKQ5$DQemymx$1;7XD*HRSl-BHpRGym%*?zQcG1Vve58z znQLx9b;b`kqwJCK#e+x3iLOnaee0|J4Yn~fp&9X~k6p9y9gd1Uv6}Cv(GK4}UVrnu zDCe&Qmnm?BmwJK>paH%6P9yio`#*-U0J}By@vyJ|1wFejaaYy>!IB%da@isdx{gB- zz;5qR*ib}!FJV8GJ}-^^AzSKx_!XdoVcsn4^vmpUS4U@G&hpqK^#mho+m|2EmJBt% za`4MB)3x2Csvn^JH)kB{TOSHmiOYXQ2ATz^?J{FMs9z2hM6A5VvW_m zH33k-q)W|QqAT@CBmhRXH-#Vc;HnQ=7^rz6RWygHL$qC*#qb25l?#W>T^X0 zB-H_F-LKbIeR5Bxd%Vvl70xN1;%(*FES&$;J_m-kc_0*0)L0(YQeTo*Vx`#de(QtF zZ`=fRIxe0FzuwLkEh}sZ1FECWKC`e*FVIlYF0d*Xu}_p>nCItv(cWtV&n?*%PIg+1 zWZj%IvjqJXmHlg@)I6>ETKE~jPu9}bVja^5SlF%jGamN?o5Atrs`p|UrOd^V#kQ2C zG~H3B@(1xzUpijcR+d4oy6_ExLAT`(*vWNHhSzUCb+0OjHxk$BQ%Gy+#yj)P&&j#^ zG`QxmD0rv!>Gl2G)rg36EULXy8l#xccz*vnBq2#QkAlWsp1&wGgo| zT?%I;#Ws;IB)$*|x))8J;_3_hdBjJ2ENg4Da#>j8zy+yT}P##R;vd^R;d zWLE{O#IIp!2yXndAdl;a(Y*l;D{rjg6JnC6PeHe_-xnd*aEAD}r;FK7c}m*+Omj%P;)nDrf{aU-#%?r2Q4H094h?PG!#u*KN?s zO{uBNsTKS>kn}@w_^qtA&c*u{gR*7#;JMU&Q|rptxl!2=rGuiX%xcw%T~eC`uQdW< z4c^(T*sclul5#d$C_j4c5kS|yv>PHdTtxRat8@-5ndNQ;hFwgU1BoIz6NOh~R|}ES zFdFIx|BYI%s`K{-?`KIsb9K8`K@HMA5%INb5c5NVpx(Q29qeF31)Kc>ZdaT1Y)q$V zV0y4YP{8uNqyKfcf5(ac`|nk~%-CU7Y+m`OcBi&hZ^~J|+XhOYc=#je>-FGfFbmo% z^D^x5^Hb3Z`KhXL9p@9~Mrw#QDK>%WvspxZKY|vr{g0`=b5tjWRUtM0R=>vWuvpR1 zk3?PT>8w{UJnU8~vYzb<>b|1o0#Jr8S9FrekHpW+As-JaqfkiT5V-$NfqzMcs0K0i zpApbZxs_fO*Ks*JKc?cR|GwE=8GdGsUCs^M3_grKf_)-k7BFpx?cmZI4ly6`5Ax+5 ztgN^c#C;njA4N1Q3_fTCfwty}-R)VYv)aN~{d(tLimP%UakrX!muG8pN<~Lg`@w=o zZkM%l^-5MNZk3fi=`>Upcdx%ctgjezP^`1482*v87vCY6{r(Ol9sYhXSZb>Ob@=hi zqVMA2oO~9L5jGsTLxj)GVgc-O87@Gphw7j4t2N}0yuyC!IM6x+bNMc83$)hQ&zVuw zi2%;K-1X#U)J=0Y+$9RP&Y*v*gfFrbI`Aq+3CTIn0TVzTxZu2_Rn1c~KcAR!`P3Uf z+F^JVX;Sgn6dgHxgDzL%JaXXq)l?2-KAg{O@{m&{j9?_D&jZS+LQ7-uw9xJU*?o@N zGERFWYE5|Ba91w(cd(8XkgDhC1X+yE6msx)bm%7|Z3YS+%{gBfE!0B=f`j8|HN*UO zQaq!7efwGI%j@DR)#>t%#gGQ`9Wz^zI2p02tZ0i4_}^vSED$rBBq!s4%*-_$hhox&62(f3EswJ@9X*0+#1m6u`S6Gso+ zpsav+Av{ps91;{T_Qj$oz2=z@GUHR2p=rqo=DcQlW)zF8{s{7hg@Q<*gN37jv;Klw zl>}KF%Ln~^P_=k9Tz!2yY1+tsUu{&xpeiz;AgVts{%Bu8m}YF_iTub?Gm9re2&GCx zJLX++gBaS7%-_rMYdSr2a#wuR>Ki^v=y@;@C7oZ&Os_p}6q^~N`%yCu>0uTG$wB&} zlzvy%qwW>gcaF?#MwHo`>emtag`Mi(1M>n24+qH7lJ#X6*joFC&UL{FC95{LDc?{JuN+2x~EQcTM+jubn~F#DK`g>LwNrse21yi8*9Je z<#BOhUl~hZPJHCd=eas?(pG3$J>Y=YRW&nDRpHYeeB=lBW-wd8c`+tPMSc6%le0qd zt_K;Is!u+8hBQZU09pkYJDG(g(3CkjxY0%XPjBM_CXlm$@YU}nY+&%w>{szlHHj2 zCK~l2EdKd$^esUI^Nr)m@0oswxKgLUJE8FFzBlgK0;jVo**eQkW!$NI|5)%y+6g(vH~a}pB?m=_fk3p(MyMKC4vdOy zr~o!C9Qao5wpu}irmBco`vIs7PVg=${cHZEsGdS#A7N9FTTCEa%sZCMZ=5I;@cZyf z2dSz=+7tBs)V+(k&ODJ z1kc~!2!JV^j$Dg2gt#?8r4k19Mh@$(yzo9~HXaHerSE=;yQwsl1(UL!s;m+Ejgatv zZpy}V6J+04p|KqCbf{D}Ok=eTDtmybfk6U2@JO~N;P>eym@H3S5;yZY1pI;3^zIb> I@!-k-0mg+#;Q#;t literal 0 HcmV?d00001 diff --git a/litellm/__init__.py b/litellm/__init__.py index 64c60ca3374..565b86f818d 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -203,6 +203,7 @@ add_user_information_to_llm_headers: Optional[ bool ] = None # adds user_id, team_id, token hash (params from StandardLoggingMetadata) to request headers store_audit_logs = False # Enterprise feature, allow users to see audit logs +skip_system_message_in_guardrail: bool = False ### end of callbacks ############# email: Optional[ diff --git a/litellm/llms/anthropic/chat/guardrail_translation/handler.py b/litellm/llms/anthropic/chat/guardrail_translation/handler.py index d31a0a091e9..2d1ca4b6e30 100644 --- a/litellm/llms/anthropic/chat/guardrail_translation/handler.py +++ b/litellm/llms/anthropic/chat/guardrail_translation/handler.py @@ -21,6 +21,10 @@ from litellm.llms.anthropic.experimental_pass_through.adapters.transformation im LiteLLMAnthropicMessagesAdapter, ) from litellm.llms.base_llm.guardrail_translation.base_translation import BaseTranslation +from litellm.llms.base_llm.guardrail_translation.utils import ( + effective_skip_system_message_for_guardrail, + openai_messages_without_system, +) from litellm.proxy.pass_through_endpoints.llm_provider_handlers.anthropic_passthrough_logging_handler import ( AnthropicPassthroughLoggingHandler, ) @@ -29,6 +33,7 @@ from litellm.types.llms.anthropic import ( AnthropicMessagesRequest, ) from litellm.types.llms.openai import ( + AllMessageValues, ChatCompletionToolCallChunk, ChatCompletionToolParam, ) @@ -75,6 +80,8 @@ class AnthropicMessagesHandler(BaseTranslation): if messages is None: return data + skip_system = effective_skip_system_message_for_guardrail(guardrail_to_apply) + ( chat_completion_compatible_request, _tool_name_mapping, @@ -83,7 +90,12 @@ class AnthropicMessagesHandler(BaseTranslation): anthropic_message_request=cast(AnthropicMessagesRequest, data.copy()) ) - structured_messages = chat_completion_compatible_request.get("messages", []) + structured_messages = cast( + List[AllMessageValues], + chat_completion_compatible_request.get("messages", []), + ) + if skip_system: + structured_messages = openai_messages_without_system(structured_messages) texts_to_check: List[str] = [] images_to_check: List[str] = [] @@ -102,6 +114,7 @@ class AnthropicMessagesHandler(BaseTranslation): texts_to_check=texts_to_check, images_to_check=images_to_check, task_mappings=task_mappings, + skip_system_message=skip_system, ) # Step 2: Apply guardrail to all texts in batch @@ -165,12 +178,16 @@ class AnthropicMessagesHandler(BaseTranslation): texts_to_check: List[str], images_to_check: List[str], task_mappings: List[Tuple[int, Optional[int]]], + skip_system_message: bool = False, ) -> None: """ Extract text content and images from a message. Override this method to customize text/image extraction logic. """ + if skip_system_message and str(message.get("role") or "").lower() == "system": + return + content = message.get("content", None) tools = message.get("tools", None) if content is None and tools is None: diff --git a/litellm/llms/base_llm/guardrail_translation/utils.py b/litellm/llms/base_llm/guardrail_translation/utils.py new file mode 100644 index 00000000000..cc401d07406 --- /dev/null +++ b/litellm/llms/base_llm/guardrail_translation/utils.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from typing import Any, List + +from litellm.types.llms.openai import AllMessageValues + + +def effective_skip_system_message_for_guardrail(guardrail_to_apply: Any) -> bool: + per = getattr(guardrail_to_apply, "skip_system_message_in_guardrail", None) + if per is not None: + return bool(per) + import litellm + + return bool(getattr(litellm, "skip_system_message_in_guardrail", False)) + + +def openai_messages_without_system( + messages: List[AllMessageValues], +) -> List[AllMessageValues]: + return [ + m + for m in messages + if str((m or {}).get("role") or "").lower() != "system" + ] diff --git a/litellm/llms/openai/chat/guardrail_translation/handler.py b/litellm/llms/openai/chat/guardrail_translation/handler.py index f854cdb13d0..2db19dea0b9 100644 --- a/litellm/llms/openai/chat/guardrail_translation/handler.py +++ b/litellm/llms/openai/chat/guardrail_translation/handler.py @@ -19,8 +19,12 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union, cast import litellm from litellm._logging import verbose_proxy_logger from litellm.llms.base_llm.guardrail_translation.base_translation import BaseTranslation +from litellm.llms.base_llm.guardrail_translation.utils import ( + effective_skip_system_message_for_guardrail, + openai_messages_without_system, +) from litellm.main import stream_chunk_builder -from litellm.types.llms.openai import ChatCompletionToolParam +from litellm.types.llms.openai import AllMessageValues, ChatCompletionToolParam from litellm.types.utils import ( Choices, GenericGuardrailAPIInputs, @@ -57,6 +61,8 @@ class OpenAIChatCompletionsHandler(BaseTranslation): if messages is None: return data + skip_system = effective_skip_system_message_for_guardrail(guardrail_to_apply) + texts_to_check: List[str] = [] images_to_check: List[str] = [] tool_calls_to_check: List[ChatCompletionToolParam] = [] @@ -76,6 +82,7 @@ class OpenAIChatCompletionsHandler(BaseTranslation): tool_calls_to_check=tool_calls_to_check, text_task_mappings=text_task_mappings, tool_call_task_mappings=tool_call_task_mappings, + skip_system_message=skip_system, ) # Step 2: Apply guardrail to all texts and tool calls in batch @@ -86,9 +93,12 @@ class OpenAIChatCompletionsHandler(BaseTranslation): if tool_calls_to_check: inputs["tool_calls"] = tool_calls_to_check # type: ignore if messages: - inputs[ - "structured_messages" - ] = messages # pass the openai /chat/completions messages to the guardrail, as-is + msg_list = cast(List[AllMessageValues], messages) + inputs["structured_messages"] = ( + openai_messages_without_system(msg_list) + if skip_system + else msg_list + ) # Pass tools (function definitions) to the guardrail tools = data.get("tools") if tools: @@ -157,12 +167,16 @@ class OpenAIChatCompletionsHandler(BaseTranslation): tool_calls_to_check: List[ChatCompletionToolParam], text_task_mappings: List[Tuple[int, Optional[int]]], tool_call_task_mappings: List[Tuple[int, int]], + skip_system_message: bool = False, ) -> None: """ Extract text content, images, and tool calls from a message. Override this method to customize text/image/tool call extraction logic. """ + if skip_system_message and str(message.get("role") or "").lower() == "system": + return + content = message.get("content", None) if content is not None: if isinstance(content, str): diff --git a/litellm/proxy/guardrails/guardrail_registry.py b/litellm/proxy/guardrails/guardrail_registry.py index d41be370f7b..96175877d65 100644 --- a/litellm/proxy/guardrails/guardrail_registry.py +++ b/litellm/proxy/guardrails/guardrail_registry.py @@ -472,6 +472,13 @@ class InMemoryGuardrailHandler: else: raise ValueError(f"Unsupported guardrail: {guardrail_type}") + if custom_guardrail_callback is not None: + setattr( + custom_guardrail_callback, + "skip_system_message_in_guardrail", + getattr(litellm_params, "skip_system_message_in_guardrail", None), + ) + parsed_guardrail = Guardrail( guardrail_id=guardrail.get("guardrail_id"), guardrail_name=guardrail["guardrail_name"], diff --git a/litellm/types/guardrails.py b/litellm/types/guardrails.py index cfec0398c81..ec869d7917d 100644 --- a/litellm/types/guardrails.py +++ b/litellm/types/guardrails.py @@ -607,6 +607,16 @@ class BaseLitellmParams( description="When True, guardrails only receive the latest message for the relevant role (e.g., newest user input pre-call, newest assistant output post-call)", ) + skip_system_message_in_guardrail: Optional[bool] = Field( + default=None, + description=( + "When True, unified guardrails skip system-role messages when building " + "evaluation inputs (texts and structured_messages). When False, system " + "messages are included even if litellm_settings sets a global skip. When " + "None, use the global litellm.skip_system_message_in_guardrail setting." + ), + ) + # Lakera specific params category_thresholds: Optional[LakeraCategoryThresholds] = Field( default=None, diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/unified_guardrails/test_unified_guardrail.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/unified_guardrails/test_unified_guardrail.py index bbba8e4d03d..11115f06d8b 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/unified_guardrails/test_unified_guardrail.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/unified_guardrails/test_unified_guardrail.py @@ -2,9 +2,17 @@ import pytest +import litellm from litellm.caching import DualCache from litellm.integrations.custom_guardrail import CustomGuardrail from litellm.llms.base_llm.guardrail_translation.base_translation import BaseTranslation +from litellm.llms.base_llm.guardrail_translation.utils import ( + effective_skip_system_message_for_guardrail, + openai_messages_without_system, +) +from litellm.llms.openai.chat.guardrail_translation.handler import ( + OpenAIChatCompletionsHandler, +) from litellm.llms.base_llm.ocr.transformation import OCRPage, OCRResponse from litellm.llms.mistral.ocr.guardrail_translation.handler import OCRHandler from litellm.proxy._experimental.mcp_server.guardrail_translation.handler import ( @@ -68,6 +76,109 @@ def _inject_mcp_handler_mapping(): class TestUnifiedLLMGuardrails: + class TestSkipSystemMessageForChatCompletions: + def test_openai_messages_without_system(self): + msgs = [ + {"role": "system", "content": "sys"}, + {"role": "user", "content": "hi"}, + ] + out = openai_messages_without_system(msgs) + assert len(out) == 1 + assert out[0]["role"] == "user" + assert msgs[0]["content"] == "sys" + + def test_effective_skip_respects_per_guardrail_over_global(self, monkeypatch): + monkeypatch.setattr( + litellm, "skip_system_message_in_guardrail", True, raising=False + ) + + class G: + skip_system_message_in_guardrail = False + + assert effective_skip_system_message_for_guardrail(G()) is False + + class G2: + skip_system_message_in_guardrail = None + + assert effective_skip_system_message_for_guardrail(G2()) is True + + @pytest.mark.asyncio + async def test_openai_handler_skips_system_in_guardrail_inputs( + self, monkeypatch + ): + monkeypatch.setattr( + litellm, "skip_system_message_in_guardrail", True, raising=False + ) + + captured = {} + + class MockGuardrail: + skip_system_message_in_guardrail = None + + async def apply_guardrail( + self, inputs, request_data, input_type, logging_obj=None + ): + captured["inputs"] = inputs + return inputs + + data = { + "messages": [ + {"role": "system", "content": "secret system"}, + {"role": "user", "content": "hello"}, + ], + "model": "gpt-4o", + } + + handler = OpenAIChatCompletionsHandler() + await handler.process_input_messages( + data=data, + guardrail_to_apply=MockGuardrail(), + litellm_logging_obj=None, + ) + + assert captured["inputs"]["texts"] == ["hello"] + sm = captured["inputs"].get("structured_messages") or [] + assert all(m.get("role") != "system" for m in sm) + assert data["messages"][0]["content"] == "secret system" + + @pytest.mark.asyncio + async def test_openai_handler_per_guardrail_skip_false_overrides_global( + self, monkeypatch + ): + monkeypatch.setattr( + litellm, "skip_system_message_in_guardrail", True, raising=False + ) + + captured = {} + + class MockGuardrail: + skip_system_message_in_guardrail = False + + async def apply_guardrail( + self, inputs, request_data, input_type, logging_obj=None + ): + captured["inputs"] = inputs + return inputs + + data = { + "messages": [ + {"role": "system", "content": "sys"}, + {"role": "user", "content": "u"}, + ], + } + + await OpenAIChatCompletionsHandler().process_input_messages( + data=data, + guardrail_to_apply=MockGuardrail(), + litellm_logging_obj=None, + ) + + assert "sys" in captured["inputs"]["texts"] + roles = { + m.get("role") for m in (captured["inputs"].get("structured_messages") or []) + } + assert "system" in roles + class TestAsyncPreCallHook: @pytest.mark.asyncio async def test_uses_mcp_event_type(self): diff --git a/ui/litellm-dashboard/src/components/guardrails/add_guardrail_form.tsx b/ui/litellm-dashboard/src/components/guardrails/add_guardrail_form.tsx index 3bdd18f2650..4cbe5664c6b 100644 --- a/ui/litellm-dashboard/src/components/guardrails/add_guardrail_form.tsx +++ b/ui/litellm-dashboard/src/components/guardrails/add_guardrail_form.tsx @@ -4,6 +4,7 @@ import NotificationsManager from "../molecules/notifications_manager"; import { createGuardrailCall, getGuardrailProviderSpecificParams, getGuardrailUISettings } from "../networking"; import ContentFilterConfiguration from "./content_filter/ContentFilterConfiguration"; import { + choiceToSkipSystemForCreate, getGuardrailProviders, guardrail_provider_map, guardrailLogoMap, @@ -179,6 +180,7 @@ const AddGuardrailForm: React.FC = ({ visible, onClose, a guardrail_name: preset.guardrailNameSuggestion, mode: preset.mode, default_on: preset.defaultOn, + skip_system_message_choice: "inherit", }; if (preset.provider === "BlockCodeExecution") { baseValues.confidence_threshold = 0.5; @@ -414,6 +416,11 @@ const AddGuardrailForm: React.FC = ({ visible, onClose, a guardrail_info: {}, }; + const skipForCreate = choiceToSkipSystemForCreate(values.skip_system_message_choice); + if (skipForCreate !== undefined) { + guardrailData.litellm_params.skip_system_message_in_guardrail = skipForCreate; + } + // For Presidio PII, add the entity and action configurations if (values.provider === "PresidioPII" && selectedEntities.length > 0) { const piiEntitiesConfig: { [key: string]: string } = {}; @@ -749,6 +756,18 @@ const AddGuardrailForm: React.FC = ({ visible, onClose, a + + + + {/* Use the GuardrailProviderFields component to render provider-specific fields */} {!isToolPermissionProvider && !shouldRenderContentFilterConfigSettings(selectedProvider) && ( = ({ visible, onClose, a initialValues={{ mode: "pre_call", default_on: false, + skip_system_message_choice: "inherit", }} > {stepConfigs.map((step, index) => { diff --git a/ui/litellm-dashboard/src/components/guardrails/edit_guardrail_form.tsx b/ui/litellm-dashboard/src/components/guardrails/edit_guardrail_form.tsx index a2cc3ad41dd..ad823df53fc 100644 --- a/ui/litellm-dashboard/src/components/guardrails/edit_guardrail_form.tsx +++ b/ui/litellm-dashboard/src/components/guardrails/edit_guardrail_form.tsx @@ -1,7 +1,12 @@ import React, { useState, useEffect } from "react"; import { Form, Typography, Select, Input, Switch, Modal } from "antd"; import { Button, TextInput } from "@tremor/react"; -import { guardrail_provider_map, guardrailLogoMap, getGuardrailProviders } from "./guardrail_info_helpers"; +import { + guardrail_provider_map, + guardrailLogoMap, + getGuardrailProviders, + type SkipSystemMessageChoice, +} from "./guardrail_info_helpers"; import { getGuardrailUISettings, getGlobalLitellmHeaderName } from "../networking"; import PiiConfiguration from "./pii_configuration"; import NotificationsManager from "../molecules/notifications_manager"; @@ -15,12 +20,15 @@ interface EditGuardrailFormProps { accessToken: string | null; onSuccess: () => void; guardrailId: string; + /** Full stored params merged into PUT so optional fields (e.g. content filter) are preserved. */ + fullLitellmParams?: Record | null; initialValues: { guardrail_name: string; provider: string; mode: string; default_on: boolean; pii_entities_config?: { [key: string]: string }; + skip_system_message_choice?: SkipSystemMessageChoice; [key: string]: any; }; } @@ -41,6 +49,7 @@ const EditGuardrailForm: React.FC = ({ accessToken, onSuccess, guardrailId, + fullLitellmParams, initialValues, }) => { const [form] = Form.useForm(); @@ -113,31 +122,23 @@ const EditGuardrailForm: React.FC = ({ // Get the guardrail provider value from the map const guardrailProvider = guardrail_provider_map[values.provider]; - // Prepare the guardrail data with proper types for litellm_params - const guardrailData: { - guardrail_id: string; - guardrail: { - guardrail_name: string; - litellm_params: { - guardrail: string; - mode: string; - default_on: boolean; - [key: string]: any; // Allow dynamic properties - }; - guardrail_info: any; - }; - } = { - guardrail_id: guardrailId, - guardrail: { - guardrail_name: values.guardrail_name, - litellm_params: { - guardrail: guardrailProvider, - mode: values.mode, - default_on: values.default_on, - }, - guardrail_info: {}, - }, - }; + const litellm_params: Record = + fullLitellmParams && typeof fullLitellmParams === "object" ? { ...fullLitellmParams } : {}; + + litellm_params.guardrail = guardrailProvider; + litellm_params.mode = values.mode; + litellm_params.default_on = values.default_on; + + const skipChoice = values.skip_system_message_choice as SkipSystemMessageChoice | undefined; + if (skipChoice === "yes") { + litellm_params.skip_system_message_in_guardrail = true; + } else if (skipChoice === "no") { + litellm_params.skip_system_message_in_guardrail = false; + } else { + delete litellm_params.skip_system_message_in_guardrail; + } + + let guardrail_info: any = {}; // For Presidio PII, add the entity and action configurations if (values.provider === "PresidioPII" && selectedEntities.length > 0) { @@ -146,7 +147,7 @@ const EditGuardrailForm: React.FC = ({ piiEntitiesConfig[entity] = selectedActions[entity] || "MASK"; // Default to MASK if no action selected }); - guardrailData.guardrail.litellm_params.pii_entities_config = piiEntitiesConfig; + litellm_params.pii_entities_config = piiEntitiesConfig; } // Add config values to the guardrail_info if provided else if (values.config) { @@ -156,14 +157,14 @@ const EditGuardrailForm: React.FC = ({ // Especially for providers like Bedrock that need guardrailIdentifier and guardrailVersion if (values.provider === "Bedrock" && configObj) { if (configObj.guardrail_id) { - guardrailData.guardrail.litellm_params.guardrailIdentifier = configObj.guardrail_id; + litellm_params.guardrailIdentifier = configObj.guardrail_id; } if (configObj.guardrail_version) { - guardrailData.guardrail.litellm_params.guardrailVersion = configObj.guardrail_version; + litellm_params.guardrailVersion = configObj.guardrail_version; } } else { // For other providers, add the config to guardrail_info - guardrailData.guardrail.guardrail_info = configObj; + guardrail_info = configObj; } } catch (error) { NotificationsManager.fromBackend("Invalid JSON in configuration"); @@ -172,6 +173,22 @@ const EditGuardrailForm: React.FC = ({ } } + const guardrailData: { + guardrail_id: string; + guardrail: { + guardrail_name: string; + litellm_params: Record; + guardrail_info: any; + }; + } = { + guardrail_id: guardrailId, + guardrail: { + guardrail_name: values.guardrail_name, + litellm_params, + guardrail_info, + }, + }; + if (!accessToken) { throw new Error("No access token available"); } @@ -403,6 +420,18 @@ const EditGuardrailForm: React.FC = ({ + + + + {renderProviderSpecificFields()}

diff --git a/ui/litellm-dashboard/src/components/guardrails/guardrail_info.tsx b/ui/litellm-dashboard/src/components/guardrails/guardrail_info.tsx index 2151a91d9d7..60400443d5c 100644 --- a/ui/litellm-dashboard/src/components/guardrails/guardrail_info.tsx +++ b/ui/litellm-dashboard/src/components/guardrails/guardrail_info.tsx @@ -25,7 +25,12 @@ import React, { useCallback, useEffect, useState } from "react"; import NotificationsManager from "../molecules/notifications_manager"; import ContentFilterManager, { formatContentFilterDataForAPI } from "./content_filter/ContentFilterManager"; import CustomCodeModal, { EditGuardrailData } from "./custom_code/CustomCodeModal"; -import { getGuardrailLogoAndName, guardrail_provider_map } from "./guardrail_info_helpers"; +import { + getGuardrailLogoAndName, + guardrail_provider_map, + skipSystemMessageToChoice, + type SkipSystemMessageChoice, +} from "./guardrail_info_helpers"; import GuardrailOptionalParams from "./guardrail_optional_params"; import GuardrailProviderFields from "./guardrail_provider_fields"; import PiiConfiguration from "./pii_configuration"; @@ -207,9 +212,14 @@ const GuardrailInfoView: React.FC = ({ guardrailId, onClose, // Reset form when guardrail data or provider params change useEffect(() => { if (guardrailData && form) { + const lp = { ...(guardrailData.litellm_params || {}) }; + delete lp.skip_system_message_in_guardrail; form.setFieldsValue({ guardrail_name: guardrailData.guardrail_name, - ...guardrailData.litellm_params, + ...lp, + skip_system_message_choice: skipSystemMessageToChoice( + guardrailData.litellm_params?.skip_system_message_in_guardrail, + ), guardrail_info: guardrailData.guardrail_info ? JSON.stringify(guardrailData.guardrail_info, null, 2) : "", // Include any optional_params if they exist ...(guardrailData.litellm_params?.optional_params && { @@ -278,6 +288,20 @@ const GuardrailInfoView: React.FC = ({ guardrailId, onClose, updateData.litellm_params.default_on = values.default_on; } + const prevSkipChoice = skipSystemMessageToChoice( + guardrailData.litellm_params?.skip_system_message_in_guardrail, + ); + const nextSkipChoice = values.skip_system_message_choice as SkipSystemMessageChoice | undefined; + if (nextSkipChoice !== undefined && nextSkipChoice !== prevSkipChoice) { + if (nextSkipChoice === "inherit") { + updateData.litellm_params.skip_system_message_in_guardrail = null; + } else if (nextSkipChoice === "yes") { + updateData.litellm_params.skip_system_message_in_guardrail = true; + } else { + updateData.litellm_params.skip_system_message_in_guardrail = false; + } + } + // Only include guardrail_info if it has changed const originalGuardrailInfo = guardrailData.guardrail_info; const newGuardrailInfo = values.guardrail_info ? JSON.parse(values.guardrail_info) : undefined; @@ -647,7 +671,14 @@ const GuardrailInfoView: React.FC = ({ guardrailId, onClose, onFinish={handleGuardrailUpdate} initialValues={{ guardrail_name: guardrailData.guardrail_name, - ...guardrailData.litellm_params, + ...(() => { + const lp = { ...(guardrailData.litellm_params || {}) }; + delete lp.skip_system_message_in_guardrail; + return lp; + })(), + skip_system_message_choice: skipSystemMessageToChoice( + guardrailData.litellm_params?.skip_system_message_in_guardrail, + ), guardrail_info: guardrailData.guardrail_info ? JSON.stringify(guardrailData.guardrail_info, null, 2) : "", @@ -673,6 +704,18 @@ const GuardrailInfoView: React.FC = ({ guardrailId, onClose, + + + + {guardrailData.litellm_params?.guardrail === "presidio" && ( <> PII Protection diff --git a/ui/litellm-dashboard/src/components/guardrails/guardrail_info_helpers.test.tsx b/ui/litellm-dashboard/src/components/guardrails/guardrail_info_helpers.test.tsx index d9e01acaadf..dfda86c1e4a 100644 --- a/ui/litellm-dashboard/src/components/guardrails/guardrail_info_helpers.test.tsx +++ b/ui/litellm-dashboard/src/components/guardrails/guardrail_info_helpers.test.tsx @@ -10,6 +10,8 @@ import { DynamicGuardrailProviders, guardrail_provider_map, GuardrailProviders, + skipSystemMessageToChoice, + choiceToSkipSystemForCreate, } from "./guardrail_info_helpers"; describe("guardrail_info_helpers", () => { @@ -199,4 +201,18 @@ describe("guardrail_info_helpers", () => { expect(result.logo).toContain("noma_security.png"); }); }); + + describe("skipSystemMessageToChoice / choiceToSkipSystemForCreate", () => { + it("maps API values to form choices and back for create", () => { + expect(skipSystemMessageToChoice(undefined)).toBe("inherit"); + expect(skipSystemMessageToChoice(null)).toBe("inherit"); + expect(skipSystemMessageToChoice(true)).toBe("yes"); + expect(skipSystemMessageToChoice(false)).toBe("no"); + + expect(choiceToSkipSystemForCreate("inherit")).toBeUndefined(); + expect(choiceToSkipSystemForCreate(undefined)).toBeUndefined(); + expect(choiceToSkipSystemForCreate("yes")).toBe(true); + expect(choiceToSkipSystemForCreate("no")).toBe(false); + }); + }); }); diff --git a/ui/litellm-dashboard/src/components/guardrails/guardrail_info_helpers.tsx b/ui/litellm-dashboard/src/components/guardrails/guardrail_info_helpers.tsx index c78835dae04..38b1b952845 100644 --- a/ui/litellm-dashboard/src/components/guardrails/guardrail_info_helpers.tsx +++ b/ui/litellm-dashboard/src/components/guardrails/guardrail_info_helpers.tsx @@ -149,3 +149,19 @@ export const getGuardrailLogoAndName = (guardrailValue: string): { logo: string; return { logo: logo || "", displayName: displayName || guardrailValue }; }; + +/** Tri-state UI value for `litellm_params.skip_system_message_in_guardrail` (inherit = use global). */ +export type SkipSystemMessageChoice = "inherit" | "yes" | "no"; + +export function skipSystemMessageToChoice(v: boolean | null | undefined): SkipSystemMessageChoice { + if (v === true) return "yes"; + if (v === false) return "no"; + return "inherit"; +} + +/** Create flow: omit key when inheriting global default. */ +export function choiceToSkipSystemForCreate(choice: SkipSystemMessageChoice | undefined): boolean | undefined { + if (choice === "yes") return true; + if (choice === "no") return false; + return undefined; +} diff --git a/ui/litellm-dashboard/src/components/guardrails/guardrail_table.tsx b/ui/litellm-dashboard/src/components/guardrails/guardrail_table.tsx index 352f3148e7b..5bb2da78fa2 100644 --- a/ui/litellm-dashboard/src/components/guardrails/guardrail_table.tsx +++ b/ui/litellm-dashboard/src/components/guardrails/guardrail_table.tsx @@ -11,7 +11,7 @@ import { SortingState, useReactTable, } from "@tanstack/react-table"; -import { getGuardrailLogoAndName, guardrail_provider_map } from "./guardrail_info_helpers"; +import { getGuardrailLogoAndName, guardrail_provider_map, skipSystemMessageToChoice } from "./guardrail_info_helpers"; import EditGuardrailForm from "./edit_guardrail_form"; import { Guardrail, GuardrailDefinitionLocation } from "./types"; @@ -291,6 +291,7 @@ const GuardrailTable: React.FC = ({ accessToken={accessToken} onSuccess={handleEditSuccess} guardrailId={selectedGuardrail.guardrail_id || ""} + fullLitellmParams={selectedGuardrail.litellm_params} initialValues={{ guardrail_name: selectedGuardrail.guardrail_name || "", provider: @@ -300,6 +301,9 @@ const GuardrailTable: React.FC = ({ mode: selectedGuardrail.litellm_params.mode, default_on: selectedGuardrail.litellm_params.default_on, pii_entities_config: selectedGuardrail.litellm_params.pii_entities_config, + skip_system_message_choice: skipSystemMessageToChoice( + selectedGuardrail.litellm_params?.skip_system_message_in_guardrail, + ), ...selectedGuardrail.guardrail_info, }} /> From 40d8a25df968dbfcecf58408951b2e3836a43863 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Sat, 11 Apr 2026 21:34:15 +0530 Subject: [PATCH 3/9] feat(bedrock): skip dummy user continue for assistant prefix prefill (#25419) When modify_params is true, Bedrock Converse setup no longer prepends or appends the default user message if the boundary assistant turn has prefix: true, so OpenAI-style assistant prefill reaches the API unchanged. Made-with: Cursor --- .../prompt_templates/factory.py | 18 ++++--- .../chat/test_converse_transformation.py | 54 +++++++++++++++++++ 2 files changed, 64 insertions(+), 8 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index d29ca1649ff..b37c17fafea 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -4371,17 +4371,19 @@ class BedrockConverseMessagesProcessor: # if initial message is assistant message if messages[0].get("role") is not None and messages[0]["role"] == "assistant": - if user_continue_message is not None: - messages.insert(0, user_continue_message) - elif litellm.modify_params: - messages.insert(0, DEFAULT_USER_CONTINUE_MESSAGE) + if not messages[0].get("prefix"): + if user_continue_message is not None: + messages.insert(0, user_continue_message) + elif litellm.modify_params: + messages.insert(0, DEFAULT_USER_CONTINUE_MESSAGE) # if final message is assistant message if messages[-1].get("role") is not None and messages[-1]["role"] == "assistant": - if user_continue_message is not None: - messages.append(user_continue_message) - elif litellm.modify_params: - messages.append(DEFAULT_USER_CONTINUE_MESSAGE) + if not messages[-1].get("prefix"): + if user_continue_message is not None: + messages.append(user_continue_message) + elif litellm.modify_params: + messages.append(DEFAULT_USER_CONTINUE_MESSAGE) return messages @staticmethod diff --git a/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py b/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py index 7ff30b36309..7719f2bc8f2 100644 --- a/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py +++ b/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py @@ -2418,6 +2418,60 @@ def test_empty_assistant_message_handling(): assert result[1]["content"][0]["text"] == "I'm doing well, thank you!" +def test_bedrock_converse_trailing_prefix_assistant_skips_user_continue(): + """Assistant prefill (prefix: true) must not inject a dummy user 'Please continue.' turn.""" + import litellm.litellm_core_utils.prompt_templates.factory as factory_module + from litellm.litellm_core_utils.prompt_templates.factory import ( + _bedrock_converse_messages_pt, + ) + + messages = [ + {"role": "user", "content": "Hello, how are you?"}, + { + "role": "assistant", + "content": "Good as", + "prefix": True, + }, + ] + + with patch.object(factory_module.litellm, "modify_params", True): + result = _bedrock_converse_messages_pt( + messages=list(messages), + model="anthropic.claude-haiku-4-5-20251001-v1:0", + llm_provider="bedrock_converse", + ) + + assert len(result) == 2 + assert result[0]["role"] == "user" + assert result[1]["role"] == "assistant" + assert result[1]["content"][0]["text"] == "Good as" + + +def test_bedrock_converse_leading_prefix_assistant_skips_user_continue(): + """Leading assistant with prefix: true should not prepend dummy user.""" + import litellm.litellm_core_utils.prompt_templates.factory as factory_module + from litellm.litellm_core_utils.prompt_templates.factory import ( + _bedrock_converse_messages_pt, + ) + + messages = [ + {"role": "assistant", "content": "Partial", "prefix": True}, + {"role": "user", "content": "Go on"}, + ] + + with patch.object(factory_module.litellm, "modify_params", True): + result = _bedrock_converse_messages_pt( + messages=list(messages), + model="anthropic.claude-haiku-4-5-20251001-v1:0", + llm_provider="bedrock_converse", + ) + + assert len(result) == 2 + assert result[0]["role"] == "assistant" + assert result[0]["content"][0]["text"] == "Partial" + assert result[1]["role"] == "user" + + def test_is_nova_2_model(): """Test the _is_nova_2_model() method for detecting Nova 2 models.""" config = AmazonConverseConfig() From d03ecedba165da8df0dfaa43a7f43f1daad20d3f Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Sat, 11 Apr 2026 21:51:01 +0530 Subject: [PATCH 4/9] feat(containers): Azure routing, managed container IDs, delete response parsing (#25287) * feat(containers): Azure container routing, managed IDs, and delete response wire format - Add AzureContainerConfig and safe URL joining for paths with api-version query - Encode/decode managed container IDs in responses, streaming, and proxy handlers - Accept OpenAI delete response object literal container.file.deleted - Tests for Azure URL regression and DeleteContainerFileResponse parsing Made-with: Cursor * fix(responses): gate response id update on parsed_chunk having response Delta stream events do not include a response body; Mock-based tests (and any truthy synthetic .response on transforms) must not trigger _update_responses_api_response_id_with_model_id. Fixes test_stop_async_iteration_not_logged_as_failure (TypeError: Mock not iterable). Made-with: Cursor * feat(containers): encode container IDs in SDK responses for routing - Add ContainerRequestUtils.encode_container_id_in_response utility - Encode container_id in create/retrieve/delete responses (SDK path) - Fix streaming iterator: gate response ID update on parsed_chunk key - Follows responses API pattern (encode after handler, not in handler) Made-with: Cursor * fix(containers): module-level imports and managed cntr_ ID encoding - Move ResponsesAPIRequestUtils imports to module scope (utils, main, handler_factory). - Serialize absent model_id as empty segment instead of literal None; decode empty and legacy "None" segments as missing for router affinity. - Add unit tests for build/decode round-trip and legacy IDs. Made-with: Cursor * fix(containers): decode managed IDs in endpoint_factory SDK path - Add decode_managed_container_id_for_request in containers/utils and reuse from main. - Strip LiteLLM cntr_ wrappers before generic_container_handler (64-char API limit). - Resolve provider for logging/errors; add unit test for decode helper. - Use resolved_custom_llm_provider after decode for mypy-safe provider typing. Made-with: Cursor * Fix p1 concern * Fix p1 concern --- litellm/containers/endpoint_factory.py | 26 +- litellm/containers/main.py | 202 +++++-- litellm/containers/utils.py | 93 +++- litellm/llms/azure/containers/__init__.py | 0 .../llms/azure/containers/transformation.py | 48 ++ .../llms/custom_httpx/container_handler.py | 21 +- .../llms/openai/containers/transformation.py | 11 +- litellm/llms/openai/containers/utils.py | 18 + .../container_endpoints/handler_factory.py | 58 +- litellm/responses/streaming_iterator.py | 65 ++- litellm/responses/utils.py | 249 +++++++++ litellm/types/containers/main.py | 3 +- litellm/utils.py | 6 + .../test_azure_container_transformation.py | 519 ++++++++++++++++++ .../containers/test_container_api.py | 71 +++ .../containers/test_container_utils.py | 47 +- .../responses/test_responses_utils.py | 29 + 17 files changed, 1381 insertions(+), 85 deletions(-) create mode 100644 litellm/llms/azure/containers/__init__.py create mode 100644 litellm/llms/azure/containers/transformation.py create mode 100644 litellm/llms/openai/containers/utils.py create mode 100644 tests/test_litellm/containers/test_azure_container_transformation.py diff --git a/litellm/containers/endpoint_factory.py b/litellm/containers/endpoint_factory.py index 1d8e50856fe..3913f3b2921 100644 --- a/litellm/containers/endpoint_factory.py +++ b/litellm/containers/endpoint_factory.py @@ -14,6 +14,7 @@ from typing import Any, Callable, Dict, List, Literal, Optional, Type import litellm from litellm.constants import request_timeout as DEFAULT_REQUEST_TIMEOUT +from litellm.containers.utils import decode_managed_container_id_for_request from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.llms.base_llm.containers.transformation import BaseContainerConfig from litellm.llms.custom_httpx.container_handler import generic_container_handler @@ -53,7 +54,7 @@ def create_sync_endpoint_function(endpoint_config: Dict) -> Callable: @client def endpoint_func( timeout: int = 600, - custom_llm_provider: Literal["openai"] = "openai", + custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai", extra_headers: Optional[Dict[str, Any]] = None, extra_query: Optional[Dict[str, Any]] = None, extra_body: Optional[Dict[str, Any]] = None, @@ -61,6 +62,7 @@ def create_sync_endpoint_function(endpoint_config: Dict) -> Callable: ): local_vars = locals() try: + resolved_custom_llm_provider: str = custom_llm_provider litellm_logging_obj: LiteLLMLoggingObj = kwargs.pop("litellm_logging_obj") litellm_call_id: Optional[str] = kwargs.get("litellm_call_id") _is_async = kwargs.pop("async_call", False) is True @@ -76,15 +78,27 @@ def create_sync_endpoint_function(endpoint_config: Dict) -> Callable: # Get provider config litellm_params = GenericLiteLLMParams(**kwargs) + # Strip LiteLLM-managed container IDs before calling the provider API + # (OpenAI enforces max length 64 on container_id). + if "container_id" in kwargs and isinstance(kwargs["container_id"], str): + ( + kwargs["container_id"], + resolved_custom_llm_provider, + litellm_params, + ) = decode_managed_container_id_for_request( + container_id=kwargs["container_id"], + custom_llm_provider=resolved_custom_llm_provider, + litellm_params=litellm_params, + ) container_provider_config: Optional[ BaseContainerConfig ] = ProviderConfigManager.get_provider_container_config( - provider=litellm.LlmProviders(custom_llm_provider), + provider=litellm.LlmProviders(resolved_custom_llm_provider), ) if container_provider_config is None: raise ValueError( - f"Container provider config not found for: {custom_llm_provider}" + f"Container provider config not found for: {resolved_custom_llm_provider}" ) # Build optional params for logging @@ -96,7 +110,7 @@ def create_sync_endpoint_function(endpoint_config: Dict) -> Callable: model="", optional_params=optional_params, litellm_params={"litellm_call_id": litellm_call_id}, - custom_llm_provider=custom_llm_provider, + custom_llm_provider=resolved_custom_llm_provider, ) # Use generic handler @@ -115,7 +129,7 @@ def create_sync_endpoint_function(endpoint_config: Dict) -> Callable: except Exception as e: raise litellm.exception_type( model="", - custom_llm_provider=custom_llm_provider, + custom_llm_provider=resolved_custom_llm_provider, original_exception=e, completion_kwargs=local_vars, extra_kwargs=kwargs, @@ -133,7 +147,7 @@ def create_async_endpoint_function( @client async def async_endpoint_func( timeout: int = 600, - custom_llm_provider: Literal["openai"] = "openai", + custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai", extra_headers: Optional[Dict[str, Any]] = None, extra_query: Optional[Dict[str, Any]] = None, extra_body: Optional[Dict[str, Any]] = None, diff --git a/litellm/containers/main.py b/litellm/containers/main.py index 916fc26351b..7532ccbc146 100644 --- a/litellm/containers/main.py +++ b/litellm/containers/main.py @@ -6,7 +6,10 @@ from typing import Any, Coroutine, Dict, List, Literal, Optional, Union, overloa import litellm from litellm.constants import request_timeout as DEFAULT_REQUEST_TIMEOUT -from litellm.containers.utils import ContainerRequestUtils +from litellm.containers.utils import ( + ContainerRequestUtils, + decode_managed_container_id_for_request, +) from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.llms.base_llm.containers.transformation import BaseContainerConfig from litellm.main import base_llm_http_handler @@ -48,7 +51,7 @@ async def acreate_container( file_ids: Optional[List[str]] = None, timeout=600, # default to 10 minutes # LiteLLM specific params, - custom_llm_provider: Literal["openai"] = "openai", + custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai", # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Optional[Dict[str, Any]] = None, @@ -122,7 +125,7 @@ def create_container( api_key: Optional[str] = None, api_base: Optional[str] = None, api_version: Optional[str] = None, - custom_llm_provider: Literal["openai"] = "openai", + custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai", *, acreate_container: Literal[True], **kwargs, @@ -139,7 +142,7 @@ def create_container( api_key: Optional[str] = None, api_base: Optional[str] = None, api_version: Optional[str] = None, - custom_llm_provider: Literal["openai"] = "openai", + custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai", *, acreate_container: Literal[False] = False, **kwargs, @@ -158,7 +161,7 @@ def create_container( api_key: Optional[str] = None, api_base: Optional[str] = None, api_version: Optional[str] = None, - custom_llm_provider: Literal["openai"] = "openai", + custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai", # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Optional[Dict[str, Any]] = None, @@ -247,7 +250,7 @@ def create_container( # Set the correct call type for container creation litellm_logging_obj.call_type = CallTypes.create_container.value - return base_llm_http_handler.container_create_handler( + container_obj = base_llm_http_handler.container_create_handler( name=name, container_create_request_params=container_create_request_params, container_provider_config=container_provider_config, @@ -257,6 +260,17 @@ def create_container( timeout=timeout or DEFAULT_REQUEST_TIMEOUT, _is_async=_is_async, ) + + # Encode container_id with provider/model metadata for routing + if isinstance(container_obj, ContainerObject): + container_obj = ContainerRequestUtils.encode_container_id_in_response( + response_obj=container_obj, + custom_llm_provider=custom_llm_provider, + litellm_metadata=kwargs.get("litellm_metadata"), + extra_body=extra_body, + ) + + return container_obj except Exception as e: raise litellm.exception_type( @@ -275,7 +289,7 @@ async def alist_containers( limit: Optional[int] = None, order: Optional[str] = None, timeout=600, # default to 10 minutes - custom_llm_provider: Literal["openai"] = "openai", + custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai", # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Optional[Dict[str, Any]] = None, @@ -348,7 +362,7 @@ def list_containers( api_key: Optional[str] = None, api_base: Optional[str] = None, api_version: Optional[str] = None, - custom_llm_provider: Literal["openai"] = "openai", + custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai", *, alist_containers: Literal[True], **kwargs, @@ -365,7 +379,7 @@ def list_containers( api_key: Optional[str] = None, api_base: Optional[str] = None, api_version: Optional[str] = None, - custom_llm_provider: Literal["openai"] = "openai", + custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai", *, alist_containers: Literal[False] = False, **kwargs, @@ -384,7 +398,7 @@ def list_containers( api_key: Optional[str] = None, api_base: Optional[str] = None, api_version: Optional[str] = None, - custom_llm_provider: Literal["openai"] = "openai", + custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai", # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Optional[Dict[str, Any]] = None, @@ -481,7 +495,7 @@ def list_containers( async def aretrieve_container( container_id: str, timeout=600, # default to 10 minutes - custom_llm_provider: Literal["openai"] = "openai", + custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai", # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Optional[Dict[str, Any]] = None, @@ -548,7 +562,7 @@ def retrieve_container( api_key: Optional[str] = None, api_base: Optional[str] = None, api_version: Optional[str] = None, - custom_llm_provider: Literal["openai"] = "openai", + custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai", *, aretrieve_container: Literal[True], **kwargs, @@ -563,7 +577,7 @@ def retrieve_container( api_key: Optional[str] = None, api_base: Optional[str] = None, api_version: Optional[str] = None, - custom_llm_provider: Literal["openai"] = "openai", + custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai", *, aretrieve_container: Literal[False] = False, **kwargs, @@ -580,7 +594,7 @@ def retrieve_container( api_key: Optional[str] = None, api_base: Optional[str] = None, api_version: Optional[str] = None, - custom_llm_provider: Literal["openai"] = "openai", + custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai", # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Optional[Dict[str, Any]] = None, @@ -594,6 +608,7 @@ def retrieve_container( """ local_vars = locals() try: + resolved_custom_llm_provider: str = custom_llm_provider litellm_logging_obj: LiteLLMLoggingObj = kwargs.pop("litellm_logging_obj") # type: ignore litellm_call_id: Optional[str] = kwargs.get("litellm_call_id") _is_async = kwargs.pop("async_call", False) is True @@ -615,16 +630,28 @@ def retrieve_container( api_version=api_version, **kwargs, ) + + # Decode container ID and extract provider info + original_container_id, resolved_custom_llm_provider, litellm_params = ( + decode_managed_container_id_for_request( + container_id=container_id, + custom_llm_provider=custom_llm_provider, + litellm_params=litellm_params, + ) + ) + # True when input was a LiteLLM-managed ID (any length); needed to re-encode output for routing affinity + was_encoded = original_container_id != container_id + # get provider config container_provider_config: Optional[ BaseContainerConfig ] = ProviderConfigManager.get_provider_container_config( - provider=litellm.LlmProviders(custom_llm_provider), + provider=litellm.LlmProviders(resolved_custom_llm_provider), ) if container_provider_config is None: raise ValueError( - f"Container provider config not found for provider: {custom_llm_provider}" + f"Container provider config not found for provider: {resolved_custom_llm_provider}" ) # Pre Call logging @@ -635,14 +662,14 @@ def retrieve_container( litellm_params={ "litellm_call_id": litellm_call_id, }, - custom_llm_provider=custom_llm_provider, + custom_llm_provider=resolved_custom_llm_provider, ) # Set the correct call type litellm_logging_obj.call_type = CallTypes.retrieve_container.value - return base_llm_http_handler.container_retrieve_handler( - container_id=container_id, + container_obj = base_llm_http_handler.container_retrieve_handler( + container_id=original_container_id, # Use decoded original ID container_provider_config=container_provider_config, litellm_params=litellm_params, logging_obj=litellm_logging_obj, @@ -651,11 +678,33 @@ def retrieve_container( timeout=timeout or DEFAULT_REQUEST_TIMEOUT, _is_async=_is_async, ) + + # Encode container_id with provider/model metadata for routing + # If input was encoded, preserve encoding in output using the decoded model_id + if isinstance(container_obj, ContainerObject): + # If input was encoded, use model_id from decoded params + litellm_metadata = kwargs.get("litellm_metadata", {}) + if was_encoded and litellm_params.get("model_id"): + # Inject model_id from decoded container_id into litellm_metadata + if not litellm_metadata: + litellm_metadata = {} + if "model_info" not in litellm_metadata: + litellm_metadata["model_info"] = {} + litellm_metadata["model_info"]["id"] = litellm_params["model_id"] + + container_obj = ContainerRequestUtils.encode_container_id_in_response( + response_obj=container_obj, + custom_llm_provider=resolved_custom_llm_provider, + litellm_metadata=litellm_metadata, + extra_body=None, + ) + + return container_obj except Exception as e: raise litellm.exception_type( model="", - custom_llm_provider=custom_llm_provider, + custom_llm_provider=resolved_custom_llm_provider, original_exception=e, completion_kwargs=local_vars, extra_kwargs=kwargs, @@ -667,7 +716,7 @@ def retrieve_container( async def adelete_container( container_id: str, timeout=600, # default to 10 minutes - custom_llm_provider: Literal["openai"] = "openai", + custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai", # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Optional[Dict[str, Any]] = None, @@ -734,7 +783,7 @@ def delete_container( api_key: Optional[str] = None, api_base: Optional[str] = None, api_version: Optional[str] = None, - custom_llm_provider: Literal["openai"] = "openai", + custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai", *, adelete_container: Literal[True], **kwargs, @@ -749,7 +798,7 @@ def delete_container( api_key: Optional[str] = None, api_base: Optional[str] = None, api_version: Optional[str] = None, - custom_llm_provider: Literal["openai"] = "openai", + custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai", *, adelete_container: Literal[False] = False, **kwargs, @@ -766,7 +815,7 @@ def delete_container( api_key: Optional[str] = None, api_base: Optional[str] = None, api_version: Optional[str] = None, - custom_llm_provider: Literal["openai"] = "openai", + custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai", # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Optional[Dict[str, Any]] = None, @@ -780,6 +829,7 @@ def delete_container( """ local_vars = locals() try: + resolved_custom_llm_provider: str = custom_llm_provider litellm_logging_obj: LiteLLMLoggingObj = kwargs.pop("litellm_logging_obj") # type: ignore litellm_call_id: Optional[str] = kwargs.get("litellm_call_id") _is_async = kwargs.pop("async_call", False) is True @@ -801,16 +851,28 @@ def delete_container( api_version=api_version, **kwargs, ) + + # Decode container ID and extract provider info + original_container_id, resolved_custom_llm_provider, litellm_params = ( + decode_managed_container_id_for_request( + container_id=container_id, + custom_llm_provider=custom_llm_provider, + litellm_params=litellm_params, + ) + ) + # True when input was a LiteLLM-managed ID (any length); needed to re-encode output for routing affinity + was_encoded = original_container_id != container_id + # get provider config container_provider_config: Optional[ BaseContainerConfig ] = ProviderConfigManager.get_provider_container_config( - provider=litellm.LlmProviders(custom_llm_provider), + provider=litellm.LlmProviders(resolved_custom_llm_provider), ) if container_provider_config is None: raise ValueError( - f"Container provider config not found for provider: {custom_llm_provider}" + f"Container provider config not found for provider: {resolved_custom_llm_provider}" ) # Pre Call logging @@ -821,14 +883,14 @@ def delete_container( litellm_params={ "litellm_call_id": litellm_call_id, }, - custom_llm_provider=custom_llm_provider, + custom_llm_provider=resolved_custom_llm_provider, ) # Set the correct call type litellm_logging_obj.call_type = CallTypes.delete_container.value - return base_llm_http_handler.container_delete_handler( - container_id=container_id, + delete_result = base_llm_http_handler.container_delete_handler( + container_id=original_container_id, # Use decoded original ID container_provider_config=container_provider_config, litellm_params=litellm_params, logging_obj=litellm_logging_obj, @@ -837,11 +899,33 @@ def delete_container( timeout=timeout or DEFAULT_REQUEST_TIMEOUT, _is_async=_is_async, ) + + # Encode container_id in response with provider/model metadata for routing + # If input was encoded, preserve encoding in output using the decoded model_id + if isinstance(delete_result, DeleteContainerResult): + # If input was encoded, use model_id from decoded params + litellm_metadata = kwargs.get("litellm_metadata", {}) + if was_encoded and litellm_params.get("model_id"): + # Inject model_id from decoded container_id into litellm_metadata + if not litellm_metadata: + litellm_metadata = {} + if "model_info" not in litellm_metadata: + litellm_metadata["model_info"] = {} + litellm_metadata["model_info"]["id"] = litellm_params["model_id"] + + delete_result = ContainerRequestUtils.encode_container_id_in_response( + response_obj=delete_result, + custom_llm_provider=resolved_custom_llm_provider, + litellm_metadata=litellm_metadata, + extra_body=None, + ) + + return delete_result except Exception as e: raise litellm.exception_type( model="", - custom_llm_provider=custom_llm_provider, + custom_llm_provider=resolved_custom_llm_provider, original_exception=e, completion_kwargs=local_vars, extra_kwargs=kwargs, @@ -856,7 +940,7 @@ async def alist_container_files( limit: Optional[int] = None, order: Optional[str] = None, timeout=600, # default to 10 minutes - custom_llm_provider: Literal["openai"] = "openai", + custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai", extra_headers: Optional[Dict[str, Any]] = None, extra_query: Optional[Dict[str, Any]] = None, extra_body: Optional[Dict[str, Any]] = None, @@ -930,7 +1014,7 @@ def list_container_files( api_key: Optional[str] = None, api_base: Optional[str] = None, api_version: Optional[str] = None, - custom_llm_provider: Literal["openai"] = "openai", + custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai", *, alist_container_files: Literal[True], **kwargs, @@ -948,7 +1032,7 @@ def list_container_files( api_key: Optional[str] = None, api_base: Optional[str] = None, api_version: Optional[str] = None, - custom_llm_provider: Literal["openai"] = "openai", + custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai", *, alist_container_files: Literal[False] = False, **kwargs, @@ -968,7 +1052,7 @@ def list_container_files( api_key: Optional[str] = None, api_base: Optional[str] = None, api_version: Optional[str] = None, - custom_llm_provider: Literal["openai"] = "openai", + custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai", extra_headers: Optional[Dict[str, Any]] = None, extra_query: Optional[Dict[str, Any]] = None, extra_body: Optional[Dict[str, Any]] = None, @@ -980,6 +1064,7 @@ def list_container_files( """ local_vars = locals() try: + resolved_custom_llm_provider: str = custom_llm_provider litellm_logging_obj: LiteLLMLoggingObj = kwargs.pop("litellm_logging_obj") # type: ignore litellm_call_id: Optional[str] = kwargs.get("litellm_call_id") _is_async = kwargs.pop("async_call", False) is True @@ -1001,16 +1086,26 @@ def list_container_files( api_version=api_version, **kwargs, ) + + # Decode container ID and extract provider info + original_container_id, resolved_custom_llm_provider, litellm_params = ( + decode_managed_container_id_for_request( + container_id=container_id, + custom_llm_provider=custom_llm_provider, + litellm_params=litellm_params, + ) + ) + # get provider config container_provider_config: Optional[ BaseContainerConfig ] = ProviderConfigManager.get_provider_container_config( - provider=litellm.LlmProviders(custom_llm_provider), + provider=litellm.LlmProviders(resolved_custom_llm_provider), ) if container_provider_config is None: raise ValueError( - f"Container provider config not found for provider: {custom_llm_provider}" + f"Container provider config not found for provider: {resolved_custom_llm_provider}" ) # Pre Call logging @@ -1026,14 +1121,14 @@ def list_container_files( litellm_params={ "litellm_call_id": litellm_call_id, }, - custom_llm_provider=custom_llm_provider, + custom_llm_provider=resolved_custom_llm_provider, ) # Set the correct call type litellm_logging_obj.call_type = CallTypes.list_container_files.value return base_llm_http_handler.container_file_list_handler( - container_id=container_id, + container_id=original_container_id, # Use decoded original ID container_provider_config=container_provider_config, litellm_params=litellm_params, logging_obj=litellm_logging_obj, @@ -1049,7 +1144,7 @@ def list_container_files( except Exception as e: raise litellm.exception_type( model="", - custom_llm_provider=custom_llm_provider, + custom_llm_provider=resolved_custom_llm_provider, original_exception=e, completion_kwargs=local_vars, extra_kwargs=kwargs, @@ -1062,7 +1157,7 @@ async def aupload_container_file( container_id: str, file: FileTypes, timeout=600, # default to 10 minutes - custom_llm_provider: Literal["openai"] = "openai", + custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai", extra_headers: Optional[Dict[str, Any]] = None, extra_query: Optional[Dict[str, Any]] = None, extra_body: Optional[Dict[str, Any]] = None, @@ -1151,7 +1246,7 @@ def upload_container_file( api_key: Optional[str] = None, api_base: Optional[str] = None, api_version: Optional[str] = None, - custom_llm_provider: Literal["openai"] = "openai", + custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai", *, aupload_container_file: Literal[True], **kwargs, @@ -1167,7 +1262,7 @@ def upload_container_file( api_key: Optional[str] = None, api_base: Optional[str] = None, api_version: Optional[str] = None, - custom_llm_provider: Literal["openai"] = "openai", + custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai", *, aupload_container_file: Literal[False] = False, **kwargs, @@ -1185,7 +1280,7 @@ def upload_container_file( api_key: Optional[str] = None, api_base: Optional[str] = None, api_version: Optional[str] = None, - custom_llm_provider: Literal["openai"] = "openai", + custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai", extra_headers: Optional[Dict[str, Any]] = None, extra_query: Optional[Dict[str, Any]] = None, extra_body: Optional[Dict[str, Any]] = None, @@ -1226,6 +1321,7 @@ def upload_container_file( local_vars = locals() try: + resolved_custom_llm_provider: str = custom_llm_provider litellm_logging_obj: LiteLLMLoggingObj = kwargs.pop("litellm_logging_obj") # type: ignore litellm_call_id: Optional[str] = kwargs.get("litellm_call_id") _is_async = kwargs.pop("async_call", False) is True @@ -1247,16 +1343,26 @@ def upload_container_file( api_version=api_version, **kwargs, ) + + # Decode container ID and extract provider info + original_container_id, resolved_custom_llm_provider, litellm_params = ( + decode_managed_container_id_for_request( + container_id=container_id, + custom_llm_provider=custom_llm_provider, + litellm_params=litellm_params, + ) + ) + # get provider config container_provider_config: Optional[ BaseContainerConfig ] = ProviderConfigManager.get_provider_container_config( - provider=litellm.LlmProviders(custom_llm_provider), + provider=litellm.LlmProviders(resolved_custom_llm_provider), ) if container_provider_config is None: raise ValueError( - f"Container provider config not found for provider: {custom_llm_provider}" + f"Container provider config not found for provider: {resolved_custom_llm_provider}" ) # Pre Call logging @@ -1267,7 +1373,7 @@ def upload_container_file( litellm_params={ "litellm_call_id": litellm_call_id, }, - custom_llm_provider=custom_llm_provider, + custom_llm_provider=resolved_custom_llm_provider, ) # Set the correct call type @@ -1282,14 +1388,14 @@ def upload_container_file( extra_query=extra_query, timeout=timeout or DEFAULT_REQUEST_TIMEOUT, _is_async=_is_async, - container_id=container_id, + container_id=original_container_id, # Use decoded original ID file=file, ) except Exception as e: raise litellm.exception_type( model="", - custom_llm_provider=custom_llm_provider, + custom_llm_provider=resolved_custom_llm_provider, original_exception=e, completion_kwargs=local_vars, extra_kwargs=kwargs, diff --git a/litellm/containers/utils.py b/litellm/containers/utils.py index 048f587fda7..976d706f71a 100644 --- a/litellm/containers/utils.py +++ b/litellm/containers/utils.py @@ -1,10 +1,38 @@ -from typing import Dict +from typing import Any, Dict, Optional, TypeVar from litellm.llms.base_llm.containers.transformation import BaseContainerConfig +from litellm.responses.utils import ResponsesAPIRequestUtils from litellm.types.containers.main import ( ContainerCreateOptionalRequestParams, ContainerListOptionalRequestParams, ) +from litellm.types.router import GenericLiteLLMParams + + +def decode_managed_container_id_for_request( + container_id: str, + custom_llm_provider: str, + litellm_params: GenericLiteLLMParams, +) -> tuple[str, str, GenericLiteLLMParams]: + """Decode a LiteLLM-managed container ID for upstream API calls. + + Returns: + (original_container_id, resolved_provider, updated_litellm_params) + """ + decoded = ResponsesAPIRequestUtils._decode_container_id(container_id) + original_container_id = decoded.get("response_id", container_id) + + decoded_provider = decoded.get("custom_llm_provider") + if decoded_provider and custom_llm_provider == "openai": + custom_llm_provider = decoded_provider + + decoded_model_id = decoded.get("model_id") + if decoded_model_id and not litellm_params.get("model_id"): + litellm_params["model_id"] = decoded_model_id + + return original_container_id, custom_llm_provider, litellm_params + +T = TypeVar("T") class ContainerRequestUtils: @@ -68,3 +96,66 @@ class ContainerRequestUtils: container_list_optional_params[param] = passed_params[param] # type: ignore return container_list_optional_params + + @staticmethod + def encode_container_id_in_response( + response_obj: T, + custom_llm_provider: Optional[str], + litellm_metadata: Optional[Dict[str, Any]] = None, + extra_body: Optional[Dict[str, Any]] = None, + ) -> T: + """ + Encode container_id in response object with provider/model metadata for routing. + + This mirrors the responses API pattern where response IDs are encoded with + routing metadata so follow-up calls can route to the correct provider. + + Encodes when: + 1. litellm_metadata contains model_info.id (indicating router/proxy usage), OR + 2. extra_body contains target_model_names (indicating model-specific routing) + + Direct SDK calls with explicit custom_llm_provider and no routing hints return raw IDs. + + Args: + response_obj: Response object with an `id` attribute (ContainerObject, DeleteContainerResult, etc.) + custom_llm_provider: Provider name (e.g., "azure", "openai") + litellm_metadata: Optional litellm_metadata dict that may contain model_info.id + extra_body: Optional extra_body dict that may contain target_model_names + + Returns: + The same response object with encoded container_id (if routing metadata present) + """ + # Extract model_id from litellm_metadata + litellm_metadata = litellm_metadata or {} + model_info: Dict[str, Any] = litellm_metadata.get("model_info", {}) or {} + model_id = model_info.get("id") + + # Check if we should encode based on routing metadata + should_encode = False + + # Case 1: Router/proxy usage (model_id from router) + if model_id is not None: + should_encode = True + + # Case 2: target_model_names in extra_body (model-specific routing) + if extra_body and "target_model_names" in extra_body: + should_encode = True + # Extract model_id from target_model_names if not already set + if model_id is None: + target_models = extra_body["target_model_names"] + # Use first model as model_id for encoding + if isinstance(target_models, str): + model_id = target_models.split(",")[0].strip() + elif isinstance(target_models, list) and len(target_models) > 0: + model_id = str(target_models[0]).strip() + + # Only encode if we have routing metadata + if should_encode and response_obj and hasattr(response_obj, "id"): + encoded_id = ResponsesAPIRequestUtils._build_container_id( + custom_llm_provider=custom_llm_provider, + model_id=model_id, + container_id=response_obj.id, + ) + response_obj.id = encoded_id + + return response_obj diff --git a/litellm/llms/azure/containers/__init__.py b/litellm/llms/azure/containers/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/litellm/llms/azure/containers/transformation.py b/litellm/llms/azure/containers/transformation.py new file mode 100644 index 00000000000..586b2e379a0 --- /dev/null +++ b/litellm/llms/azure/containers/transformation.py @@ -0,0 +1,48 @@ +from typing import Optional + +from litellm.llms.azure.common_utils import BaseAzureLLM +from litellm.llms.openai.containers.transformation import OpenAIContainerConfig +from litellm.types.router import GenericLiteLLMParams + + +class AzureContainerConfig(OpenAIContainerConfig): + """ + Configuration class for Azure OpenAI container API. + + Inherits request/response transformations from OpenAIContainerConfig since + Azure's container API is wire-compatible with OpenAI's. Only overrides + authentication (api-key header) and URL construction (openai/v1/containers path). + + Azure container API reference: + https://learn.microsoft.com/en-us/azure/foundry/openai/latest#containers + """ + + def validate_environment( + self, + headers: dict, + api_key: Optional[str] = None, + ) -> dict: + return BaseAzureLLM._base_validate_azure_environment( + headers=headers, + litellm_params=GenericLiteLLMParams(api_key=api_key), + ) + + def get_complete_url( + self, + api_base: Optional[str], + litellm_params: dict, + ) -> str: + """ + Build the Azure container endpoint URL. + + Azure container API uses the path: + {endpoint}/openai/v1/containers + when api_version is 'v1', 'latest', or 'preview'; otherwise: + {endpoint}/openai/containers + """ + return BaseAzureLLM._get_base_azure_url( + api_base=api_base, + litellm_params=litellm_params, + route="/openai/containers", + default_api_version="v1", + ) diff --git a/litellm/llms/custom_httpx/container_handler.py b/litellm/llms/custom_httpx/container_handler.py index 3767949375d..2d54f33bf96 100644 --- a/litellm/llms/custom_httpx/container_handler.py +++ b/litellm/llms/custom_httpx/container_handler.py @@ -61,18 +61,29 @@ def _build_url( ) -> str: """Build the full URL by substituting path parameters. - The api_base from get_complete_url already includes /containers, - so we need to strip that prefix from the path_template. + The api_base from get_complete_url already includes /containers and may include + query parameters. We need to parse the URL, append the path, then preserve the + query parameters. """ # api_base ends with /containers, path_template starts with /containers # So we need to strip /containers from the path if path_template.startswith("/containers"): path_template = path_template[len("/containers") :] - url = f"{api_base.rstrip('/')}{path_template}" + # Substitute path parameters for param, value in path_params.items(): - url = url.replace(f"{{{param}}}", value) - return url + path_template = path_template.replace(f"{{{param}}}", value) + + # Parse the api_base to extract existing query params + parsed_base = httpx.URL(api_base) + + # Append the path to the existing path (before query params) + new_path = f"{parsed_base.path.rstrip('/')}{path_template}" + + # Rebuild URL with new path, preserving query params + final_url = parsed_base.copy_with(path=new_path) + + return str(final_url) def _build_query_params( diff --git a/litellm/llms/openai/containers/transformation.py b/litellm/llms/openai/containers/transformation.py index 645538fdd9c..955b9f760d1 100644 --- a/litellm/llms/openai/containers/transformation.py +++ b/litellm/llms/openai/containers/transformation.py @@ -17,6 +17,7 @@ from litellm.types.containers.main import ( from litellm.types.router import GenericLiteLLMParams from ...base_llm.containers.transformation import BaseContainerConfig +from .utils import join_container_api_base_path if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj @@ -197,7 +198,7 @@ class OpenAIContainerConfig(BaseContainerConfig): ) -> Tuple[str, Dict]: """Transform the OpenAI container retrieve request.""" # For container retrieve, we just need to construct the URL - url = f"{api_base.rstrip('/')}/{container_id}" + url = join_container_api_base_path(api_base, f"/{container_id}") # No additional data needed for GET request data: Dict[str, Any] = {} @@ -229,7 +230,7 @@ class OpenAIContainerConfig(BaseContainerConfig): - DELETE /v1/containers/{container_id} """ # Construct the URL for container delete - url = f"{api_base.rstrip('/')}/{container_id}" + url = join_container_api_base_path(api_base, f"/{container_id}") # No data needed for DELETE request data: Dict[str, Any] = {} @@ -266,7 +267,7 @@ class OpenAIContainerConfig(BaseContainerConfig): - GET /v1/containers/{container_id}/files """ # Construct the URL for container files - url = f"{api_base.rstrip('/')}/{container_id}/files" + url = join_container_api_base_path(api_base, f"/{container_id}/files") # Prepare query parameters params: Dict[str, Any] = {} @@ -310,7 +311,9 @@ class OpenAIContainerConfig(BaseContainerConfig): - GET /v1/containers/{container_id}/files/{file_id}/content """ # Construct the URL for container file content - url = f"{api_base.rstrip('/')}/{container_id}/files/{file_id}/content" + url = join_container_api_base_path( + api_base, f"/{container_id}/files/{file_id}/content" + ) # No query parameters needed params: Dict[str, Any] = {} diff --git a/litellm/llms/openai/containers/utils.py b/litellm/llms/openai/containers/utils.py new file mode 100644 index 00000000000..c4ac35a2f85 --- /dev/null +++ b/litellm/llms/openai/containers/utils.py @@ -0,0 +1,18 @@ +"""Shared helpers for OpenAI-compatible container API URL construction.""" + +import httpx + + +def join_container_api_base_path(api_base: str, path_suffix: str) -> str: + """Append ``path_suffix`` to the path of ``api_base``, keeping the query string last. + + Azure (and some bases) pass ``api_base`` like + ``https://host/openai/v1/containers?api-version=v1``. Naive string concat would + produce ``...?api-version=v1/cntr_...`` which is invalid; this uses ``httpx.URL`` + so the result is ``.../containers/cntr_.../files?api-version=v1``. + """ + if not path_suffix.startswith("/"): + path_suffix = f"/{path_suffix}" + parsed = httpx.URL(api_base) + new_path = f"{parsed.path.rstrip('/')}{path_suffix}" + return str(parsed.copy_with(path=new_path)) diff --git a/litellm/proxy/container_endpoints/handler_factory.py b/litellm/proxy/container_endpoints/handler_factory.py index 078f0c9bc49..f2b23ff95b0 100644 --- a/litellm/proxy/container_endpoints/handler_factory.py +++ b/litellm/proxy/container_endpoints/handler_factory.py @@ -19,6 +19,7 @@ from litellm.proxy.common_utils.openai_endpoint_utils import ( get_custom_llm_provider_from_request_headers, get_custom_llm_provider_from_request_query, ) +from litellm.responses.utils import ResponsesAPIRequestUtils def _load_endpoints_config() -> Dict: @@ -40,10 +41,13 @@ def _get_container_provider_config(custom_llm_provider: str): from litellm.llms.openai.containers.transformation import OpenAIContainerConfig return OpenAIContainerConfig() - else: - raise ValueError( - f"Container API not supported for provider: {custom_llm_provider}" - ) + elif custom_llm_provider in ("azure", "azure_text"): + from litellm.llms.azure.containers.transformation import AzureContainerConfig + + return AzureContainerConfig() + raise ValueError( + f"Container API not supported for provider: {custom_llm_provider}" + ) def _create_handler_for_path_params( @@ -171,12 +175,21 @@ async def _process_binary_request( or "openai" ) - # Get the provider config - container_provider_config = _get_container_provider_config(custom_llm_provider) - # Build litellm_params - credentials are resolved by provider config from env litellm_params = GenericLiteLLMParams() + # Decode container ID and extract provider info + decoded = ResponsesAPIRequestUtils._decode_container_id(container_id) + original_container_id = decoded.get("response_id", container_id) + + # If container ID has encoded provider info and user didn't explicitly set provider, use it + decoded_provider = decoded.get("custom_llm_provider") + if decoded_provider and custom_llm_provider == "openai": + custom_llm_provider = decoded_provider + + # Get the provider config + container_provider_config = _get_container_provider_config(custom_llm_provider) + # Create logging object logging_obj = Logging( model="container-file-content", @@ -193,7 +206,7 @@ async def _process_binary_request( try: content = await handler.async_container_file_content_handler( - container_id=container_id, + container_id=original_container_id, # Use decoded original ID file_id=file_id, container_provider_config=container_provider_config, litellm_params=litellm_params, @@ -267,13 +280,22 @@ async def _process_multipart_upload_request( if isinstance(file_list, list) and len(file_list) > 0: data["file"] = file_list[0] - data["container_id"] = container_id - custom_llm_provider = ( get_custom_llm_provider_from_request_headers(request=request) or get_custom_llm_provider_from_request_query(request=request) or "openai" ) + + # Decode container ID and extract provider info + decoded = ResponsesAPIRequestUtils._decode_container_id(container_id) + original_container_id = decoded.get("response_id", container_id) + + # If container ID has encoded provider info and user didn't explicitly set provider, use it + decoded_provider = decoded.get("custom_llm_provider") + if decoded_provider and custom_llm_provider == "openai": + custom_llm_provider = decoded_provider + + data["container_id"] = original_container_id # Use decoded original ID data["custom_llm_provider"] = custom_llm_provider processor = ProxyBaseLLMRequestProcessing(data=data) @@ -338,6 +360,22 @@ async def _process_request( or get_custom_llm_provider_from_request_query(request=request) or "openai" ) + + # Decode container_id if present in path_params + if "container_id" in path_params: + decoded = ResponsesAPIRequestUtils._decode_container_id( + path_params["container_id"] + ) + original_container_id = decoded.get("response_id", path_params["container_id"]) + + # If container ID has encoded provider info and user didn't explicitly set provider, use it + decoded_provider = decoded.get("custom_llm_provider") + if decoded_provider and custom_llm_provider == "openai": + custom_llm_provider = decoded_provider + + # Update path_params with decoded original ID + data["container_id"] = original_container_id + data["custom_llm_provider"] = custom_llm_provider processor = ProxyBaseLLMRequestProcessing(data=data) diff --git a/litellm/responses/streaming_iterator.py b/litellm/responses/streaming_iterator.py index 10a74a5b3c6..2ecc95b7b32 100644 --- a/litellm/responses/streaming_iterator.py +++ b/litellm/responses/streaming_iterator.py @@ -130,15 +130,64 @@ class BaseResponsesAPIStreamingIterator: ) ) - # if "response" in parsed_chunk, then encode litellm specific information like custom_llm_provider - response_object = getattr(openai_responses_api_chunk, "response", None) - if response_object: - response = ResponsesAPIRequestUtils._update_responses_api_response_id_with_model_id( - responses_api_response=response_object, - litellm_metadata=self.litellm_metadata, - custom_llm_provider=self.custom_llm_provider, + # Only when the SSE JSON carries a response body (delta events do not). + # Using getattr(..., "response") alone is unsafe with Mocks: they synthesize a + # truthy child Mock for any attribute, which breaks tests and is wrong on stream. + if "response" in parsed_chunk: + response_object = getattr( + openai_responses_api_chunk, "response", None ) - setattr(openai_responses_api_chunk, "response", response) + if response_object is not None: + response = ResponsesAPIRequestUtils._update_responses_api_response_id_with_model_id( + responses_api_response=response_object, + litellm_metadata=self.litellm_metadata, + custom_llm_provider=self.custom_llm_provider, + ) + setattr(openai_responses_api_chunk, "response", response) + + # Encode container_id on streaming events so proxy/UI follow-ups route correctly + _event_type = getattr(openai_responses_api_chunk, "type", None) + _stream_model_id = ( + self.litellm_metadata.get("model_info", {}).get("id") + if self.litellm_metadata + else None + ) + if _event_type in ( + ResponsesAPIStreamEvents.OUTPUT_ITEM_ADDED, + ResponsesAPIStreamEvents.OUTPUT_ITEM_DONE, + ): + _item = getattr(openai_responses_api_chunk, "item", None) + if _item is not None: + ResponsesAPIRequestUtils._encode_container_id_on_output_item( + item=_item, + custom_llm_provider=self.custom_llm_provider, + model_id=_stream_model_id, + ) + elif _event_type == ResponsesAPIStreamEvents.OUTPUT_TEXT_ANNOTATION_ADDED: + _annotation = getattr( + openai_responses_api_chunk, "annotation", None + ) + if _annotation is not None: + ResponsesAPIRequestUtils._encode_container_id_on_output_item( + item=_annotation, + custom_llm_provider=self.custom_llm_provider, + model_id=_stream_model_id, + ) + elif _event_type == ResponsesAPIStreamEvents.CONTENT_PART_DONE: + _part = getattr(openai_responses_api_chunk, "part", None) + if _part is not None: + if isinstance(_part, dict): + ResponsesAPIRequestUtils._encode_container_ids_in_annotations( + _part.get("annotations"), + self.custom_llm_provider, + _stream_model_id, + ) + else: + ResponsesAPIRequestUtils._encode_container_ids_in_annotations( + getattr(_part, "annotations", None), + self.custom_llm_provider, + _stream_model_id, + ) # Wrap encrypted_content in streaming events (output_item.added, output_item.done) if self.litellm_metadata and self.litellm_metadata.get( diff --git a/litellm/responses/utils.py b/litellm/responses/utils.py index 11097864225..bc9fe3897a3 100644 --- a/litellm/responses/utils.py +++ b/litellm/responses/utils.py @@ -1,4 +1,5 @@ import base64 +import re from typing import ( Any, Dict, @@ -226,6 +227,15 @@ class ResponsesAPIRequestUtils: ) ) + # Encode container IDs in the response output + responses_api_response = ( + ResponsesAPIRequestUtils._update_container_ids_in_response( + responses_api_response=responses_api_response, + custom_llm_provider=custom_llm_provider, + litellm_metadata=litellm_metadata, + ) + ) + return responses_api_response @staticmethod @@ -522,6 +532,245 @@ class ResponsesAPIRequestUtils: ) return decoded_response_id.get("response_id", previous_response_id) + @staticmethod + def _build_container_id( + custom_llm_provider: Optional[str], + model_id: Optional[str], + container_id: str, + ) -> str: + """Build a managed container ID with provider and model info encoded. + + Format: cntr_{base64("litellm:custom_llm_provider:{provider};model_id:{model};container_id:{original}")} + """ + # Avoid serializing Python None as the literal string "None" (breaks router affinity). + provider_part = "" if custom_llm_provider is None else custom_llm_provider + model_part = "" if model_id is None else model_id + assembled_id = f"litellm:custom_llm_provider:{provider_part};model_id:{model_part};container_id:{container_id}" + base64_encoded_id = base64.b64encode(assembled_id.encode("utf-8")).decode("utf-8") + return f"cntr_{base64_encoded_id}" + + @staticmethod + def _decode_container_id(container_id: str) -> DecodedResponseId: + """Decode a managed container ID to extract provider, model, and original container ID. + + Returns: + DecodedResponseId with custom_llm_provider, model_id, and response_id (original container_id) + """ + try: + # If it doesn't start with cntr_, it's not a managed ID + if not container_id.startswith("cntr_"): + return DecodedResponseId( + custom_llm_provider=None, + model_id=None, + response_id=container_id, + ) + + # Remove prefix and decode + cleaned_id = container_id.replace("cntr_", "") + decoded_id = base64.b64decode(cleaned_id.encode("utf-8")).decode("utf-8") + + # Parse components using regex to handle semicolons in the container_id + if not decoded_id.startswith("litellm:"): + return DecodedResponseId( + custom_llm_provider=None, + model_id=None, + response_id=container_id, + ) + + # Use regex to extract the three parts, allowing semicolons in container_id + # Format: litellm:custom_llm_provider:{provider};model_id:{model};container_id:{container} + # * for provider/model allows empty segments (missing router model_id). + pattern = r"^litellm:custom_llm_provider:([^;]*);model_id:([^;]*);container_id:(.+)$" + match = re.match(pattern, decoded_id) + + if not match: + return DecodedResponseId( + custom_llm_provider=None, + model_id=None, + response_id=container_id, + ) + + raw_provider = match.group(1) + raw_model_id = match.group(2) + custom_llm_provider = ( + None if raw_provider in ("", "None") else raw_provider + ) + model_id = None if raw_model_id in ("", "None") else raw_model_id + original_container_id = match.group(3) + + return DecodedResponseId( + custom_llm_provider=custom_llm_provider, + model_id=model_id, + response_id=original_container_id, + ) + except Exception as e: + verbose_logger.debug(f"Error decoding container_id '{container_id}': {e}") + return DecodedResponseId( + custom_llm_provider=None, + model_id=None, + response_id=container_id, + ) + + @staticmethod + def decode_container_id_to_original(container_id: str) -> str: + """Decode a managed container ID to get the original provider-issued ID. + + This is used when making upstream API calls - we need to send the original + container ID that the provider issued, not our encoded version. + """ + decoded = ResponsesAPIRequestUtils._decode_container_id(container_id) + return decoded.get("response_id", container_id) + + @staticmethod + def _encode_container_ids_in_annotations( + annotations: Any, + custom_llm_provider: Optional[str], + model_id: Optional[str], + ) -> None: + """Encode ``container_id`` on each annotation (e.g. ``container_file_citation``).""" + if not annotations or not isinstance(annotations, list): + return + for ann in annotations: + ResponsesAPIRequestUtils._encode_container_id_on_output_item( + ann, + custom_llm_provider, + model_id, + ) + + @staticmethod + def _encode_container_ids_in_message_content( + content: Any, + custom_llm_provider: Optional[str], + model_id: Optional[str], + ) -> None: + """Walk message ``content`` parts and encode citation ``container_id`` values.""" + if not content: + return + if isinstance(content, list): + for part in content: + if isinstance(part, dict): + ResponsesAPIRequestUtils._encode_container_ids_in_annotations( + part.get("annotations"), + custom_llm_provider, + model_id, + ) + else: + ResponsesAPIRequestUtils._encode_container_ids_in_annotations( + getattr(part, "annotations", None), + custom_llm_provider, + model_id, + ) + + @staticmethod + def _encode_container_id_on_output_item( + item: Any, + custom_llm_provider: Optional[str], + model_id: Optional[str], + ) -> None: + """Mutate one output item (dict or object): wrap raw ``container_id`` as LiteLLM-managed. + + Handles top-level ``container_id`` and nested ``code_interpreter_call.container_id`` + (some wire payloads nest the tool call). Used by non-streaming responses and by + streaming ``response.output_item.*`` events so UIs see managed IDs incrementally. + + For ``message`` items, also encodes ``container_id`` inside + ``content[].annotations`` (``container_file_citation``), which is what clients use + to fetch generated files. + """ + if item is None: + return + + def _maybe_encode(container_id: str) -> Optional[str]: + decoded = ResponsesAPIRequestUtils._decode_container_id(container_id) + if decoded.get("custom_llm_provider") is not None: + return None + return ResponsesAPIRequestUtils._build_container_id( + custom_llm_provider=custom_llm_provider, + model_id=model_id, + container_id=container_id, + ) + + if isinstance(item, dict): + cid = item.get("container_id") + if isinstance(cid, str): + enc = _maybe_encode(cid) + if enc is not None: + item["container_id"] = enc + nested = item.get("code_interpreter_call") + if isinstance(nested, dict): + nc = nested.get("container_id") + if isinstance(nc, str): + enc = _maybe_encode(nc) + if enc is not None: + nested["container_id"] = enc + if item.get("type") == "message": + ResponsesAPIRequestUtils._encode_container_ids_in_message_content( + item.get("content"), + custom_llm_provider, + model_id, + ) + return + + cid_attr = getattr(item, "container_id", None) + if isinstance(cid_attr, str): + enc = _maybe_encode(cid_attr) + if enc is not None: + try: + setattr(item, "container_id", enc) + except Exception: + verbose_logger.debug( + "Could not set container_id on streaming output item", + exc_info=True, + ) + + nested_obj = getattr(item, "code_interpreter_call", None) + if nested_obj is not None: + ResponsesAPIRequestUtils._encode_container_id_on_output_item( + nested_obj, + custom_llm_provider, + model_id, + ) + + if getattr(item, "type", None) == "message": + ResponsesAPIRequestUtils._encode_container_ids_in_message_content( + getattr(item, "content", None), + custom_llm_provider, + model_id, + ) + + @staticmethod + def _update_container_ids_in_response( + responses_api_response: Union[ResponsesAPIResponse, Dict[str, Any]], + custom_llm_provider: Optional[str], + litellm_metadata: Optional[Dict[str, Any]] = None, + ) -> Union[ResponsesAPIResponse, Dict[str, Any]]: + """Encode container IDs in the response output with provider/model info. + + This walks through all output items and encodes any container_id fields + so that follow-up container API calls can auto-route to the correct provider. + """ + litellm_metadata = litellm_metadata or {} + model_info: Dict[str, Any] = litellm_metadata.get("model_info", {}) or {} + model_id = model_info.get("id") + + # Get the output list + if isinstance(responses_api_response, dict): + output = responses_api_response.get("output", []) + else: + output = getattr(responses_api_response, "output", []) + + if not output: + return responses_api_response + + for item in output: + ResponsesAPIRequestUtils._encode_container_id_on_output_item( + item=item, + custom_llm_provider=custom_llm_provider, + model_id=model_id, + ) + + return responses_api_response + @staticmethod def convert_text_format_to_text_param( text_format: Optional[Union[Type["BaseModel"], dict]], diff --git a/litellm/types/containers/main.py b/litellm/types/containers/main.py index df8c05a74c6..0b0bef39e18 100644 --- a/litellm/types/containers/main.py +++ b/litellm/types/containers/main.py @@ -187,7 +187,8 @@ class DeleteContainerFileResponse(BaseModel): """Response object for delete container file request.""" id: str - object: Literal["container_file.deleted"] + # OpenAI / Azure wire format uses dots; keep underscore variant for compatibility. + object: Literal["container.file.deleted", "container_file.deleted"] deleted: bool def __contains__(self, key): diff --git a/litellm/utils.py b/litellm/utils.py index f902644e760..970ca0ec8e6 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -8955,6 +8955,12 @@ class ProviderConfigManager: ) return OpenAIContainerConfig() + if provider in (LlmProviders.AZURE, LlmProviders.AZURE_TEXT): + from litellm.llms.azure.containers.transformation import ( + AzureContainerConfig, + ) + + return AzureContainerConfig() return None @staticmethod diff --git a/tests/test_litellm/containers/test_azure_container_transformation.py b/tests/test_litellm/containers/test_azure_container_transformation.py new file mode 100644 index 00000000000..de79557ea03 --- /dev/null +++ b/tests/test_litellm/containers/test_azure_container_transformation.py @@ -0,0 +1,519 @@ +import os +import sys +from unittest.mock import MagicMock +from urllib.parse import parse_qs, urlparse + +import httpx +import pytest + +sys.path.insert(0, os.path.abspath("../../../")) + +import litellm +from litellm.llms.azure.containers.transformation import AzureContainerConfig +from litellm.llms.base_llm.containers.transformation import BaseContainerConfig +from litellm.types.containers.main import ( + ContainerFileListResponse, + ContainerListResponse, + ContainerObject, + DeleteContainerResult, +) +from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLogging + + +class TestAzureContainerConfig: + """Test suite for Azure container transformation functionality.""" + + def setup_method(self): + self.config = AzureContainerConfig() + self.logging_obj = LiteLLMLogging( + model="", + messages=[], + stream=False, + call_type="create_container", + start_time=None, + litellm_call_id="test_call_id", + function_id="test_function_id", + ) + + def test_inherits_base_container_config(self): + assert isinstance(self.config, BaseContainerConfig) + + def test_get_supported_openai_params(self): + supported_params = self.config.get_supported_openai_params() + assert "name" in supported_params + assert "expires_after" in supported_params + assert "file_ids" in supported_params + + def test_validate_environment_with_api_key(self): + headers = {} + api_key = "test-azure-key" + + validated_headers = self.config.validate_environment( + headers=headers, api_key=api_key + ) + + assert "api-key" in validated_headers + assert validated_headers["api-key"] == api_key + + def test_validate_environment_uses_azure_env_var(self, monkeypatch): + monkeypatch.setenv("AZURE_API_KEY", "env-azure-key") + headers = {} + + validated_headers = self.config.validate_environment(headers=headers) + + assert "api-key" in validated_headers + assert validated_headers["api-key"] == "env-azure-key" + + def test_validate_environment_no_bearer_token(self): + """Azure uses api-key header, not Authorization: Bearer.""" + headers = {} + api_key = "azure-test-key" + + validated_headers = self.config.validate_environment( + headers=headers, api_key=api_key + ) + + assert "Authorization" not in validated_headers + assert "api-key" in validated_headers + + def test_get_complete_url_default_v1(self): + """With default_api_version='v1', URL should include /openai/v1/containers.""" + api_base = "https://my-resource.openai.azure.com" + litellm_params = {} + + url = self.config.get_complete_url( + api_base=api_base, litellm_params=litellm_params + ) + + assert "/openai/v1/containers" in url + assert "my-resource.openai.azure.com" in url + + def test_get_complete_url_with_explicit_api_version(self): + api_base = "https://my-resource.openai.azure.com" + litellm_params = {"api_version": "2025-01-01"} + + url = self.config.get_complete_url( + api_base=api_base, litellm_params=litellm_params + ) + + assert "api-version=2025-01-01" in url + assert "/openai/containers" in url + + def test_get_complete_url_with_latest_api_version(self): + api_base = "https://my-resource.openai.azure.com" + litellm_params = {"api_version": "latest"} + + url = self.config.get_complete_url( + api_base=api_base, litellm_params=litellm_params + ) + + assert "/openai/v1/containers" in url + + def test_get_complete_url_raises_without_api_base(self, monkeypatch): + monkeypatch.delenv("AZURE_API_BASE", raising=False) + monkeypatch.setattr(litellm, "api_base", None) + with pytest.raises(ValueError, match="api_base is required"): + self.config.get_complete_url(api_base=None, litellm_params={}) + + def test_transform_container_create_request(self): + from litellm.types.router import GenericLiteLLMParams + + litellm_params = GenericLiteLLMParams() + headers = {"api-key": "test-key"} + name = "My Azure Container" + optional_params = { + "expires_after": {"anchor": "last_active_at", "minutes": 30}, + "file_ids": ["file_abc"], + } + + data = self.config.transform_container_create_request( + name=name, + container_create_optional_request_params=optional_params, + litellm_params=litellm_params, + headers=headers, + ) + + assert data["name"] == name + assert data["expires_after"]["minutes"] == 30 + assert data["file_ids"] == ["file_abc"] + + def test_transform_container_create_response(self): + mock_response = MagicMock(spec=httpx.Response) + mock_response.json.return_value = { + "id": "cntr_azure_123", + "object": "container", + "created_at": 1747857508, + "status": "running", + "expires_after": {"anchor": "last_active_at", "minutes": 30}, + "last_active_at": 1747857508, + "name": "My Azure Container", + } + + container = self.config.transform_container_create_response( + raw_response=mock_response, logging_obj=self.logging_obj + ) + + assert isinstance(container, ContainerObject) + assert container.id == "cntr_azure_123" + assert container.name == "My Azure Container" + assert container.status == "running" + + def test_transform_container_list_request(self): + from litellm.types.router import GenericLiteLLMParams + + api_base = "https://my-resource.openai.azure.com/openai/v1/containers" + litellm_params = GenericLiteLLMParams() + headers = {"api-key": "test-key"} + + url, params = self.config.transform_container_list_request( + api_base=api_base, + litellm_params=litellm_params, + headers=headers, + limit=5, + order="desc", + ) + + assert url == api_base + assert params["limit"] == "5" + assert params["order"] == "desc" + + def test_transform_container_list_response(self): + mock_response = MagicMock(spec=httpx.Response) + mock_response.json.return_value = { + "object": "list", + "data": [ + { + "id": "cntr_1", + "object": "container", + "created_at": 1747857508, + "status": "running", + "expires_after": {"anchor": "last_active_at", "minutes": 20}, + "last_active_at": 1747857508, + "name": "Container 1", + } + ], + "first_id": "cntr_1", + "last_id": "cntr_1", + "has_more": False, + } + + container_list = self.config.transform_container_list_response( + raw_response=mock_response, logging_obj=self.logging_obj + ) + + assert isinstance(container_list, ContainerListResponse) + assert len(container_list.data) == 1 + assert container_list.first_id == "cntr_1" + + def test_transform_container_retrieve_request(self): + from litellm.types.router import GenericLiteLLMParams + + container_id = "cntr_azure_abc" + api_base = "https://my-resource.openai.azure.com/openai/v1/containers" + litellm_params = GenericLiteLLMParams() + headers = {"api-key": "test-key"} + + url, params = self.config.transform_container_retrieve_request( + container_id=container_id, + api_base=api_base, + litellm_params=litellm_params, + headers=headers, + ) + + assert url == f"{api_base}/{container_id}" + assert params == {} + + def test_transform_container_delete_request(self): + from litellm.types.router import GenericLiteLLMParams + + container_id = "cntr_azure_del" + api_base = "https://my-resource.openai.azure.com/openai/v1/containers" + litellm_params = GenericLiteLLMParams() + headers = {"api-key": "test-key"} + + url, params = self.config.transform_container_delete_request( + container_id=container_id, + api_base=api_base, + litellm_params=litellm_params, + headers=headers, + ) + + assert url == f"{api_base}/{container_id}" + assert params == {} + + def test_transform_container_delete_response(self): + mock_response = MagicMock(spec=httpx.Response) + mock_response.json.return_value = { + "id": "cntr_azure_del", + "object": "container.deleted", + "deleted": True, + } + + delete_result = self.config.transform_container_delete_response( + raw_response=mock_response, logging_obj=self.logging_obj + ) + + assert isinstance(delete_result, DeleteContainerResult) + assert delete_result.id == "cntr_azure_del" + assert delete_result.deleted is True + + def test_transform_container_file_list_request(self): + from litellm.types.router import GenericLiteLLMParams + + container_id = "cntr_azure_files" + api_base = "https://my-resource.openai.azure.com/openai/v1/containers" + litellm_params = GenericLiteLLMParams() + headers = {"api-key": "test-key"} + + url, params = self.config.transform_container_file_list_request( + container_id=container_id, + api_base=api_base, + litellm_params=litellm_params, + headers=headers, + limit=10, + ) + + assert url == f"{api_base}/{container_id}/files" + assert params["limit"] == "10" + + def test_transform_requests_preserve_query_string_after_path(self): + """api-version must not appear before /{container_id}/... (Azure bases include ?).""" + from litellm.types.router import GenericLiteLLMParams + + api_base = ( + "https://my-resource.openai.azure.com/openai/v1/containers" + "?api-version=v1" + ) + litellm_params = GenericLiteLLMParams() + headers: dict = {} + + url_r, _ = self.config.transform_container_retrieve_request( + container_id="cntr_x", + api_base=api_base, + litellm_params=litellm_params, + headers=headers, + ) + assert ( + url_r + == "https://my-resource.openai.azure.com/openai/v1/containers/cntr_x?api-version=v1" + ) + + url_fl, _ = self.config.transform_container_file_list_request( + container_id="cntr_x", + api_base=api_base, + litellm_params=litellm_params, + headers=headers, + ) + assert ( + url_fl + == "https://my-resource.openai.azure.com/openai/v1/containers/cntr_x/files?api-version=v1" + ) + + url_fc, _ = self.config.transform_container_file_content_request( + container_id="cntr_x", + file_id="cfile_y", + api_base=api_base, + litellm_params=litellm_params, + headers=headers, + ) + expected_fc = ( + "https://my-resource.openai.azure.com/openai/v1/containers/" + "cntr_x/files/cfile_y/content?api-version=v1" + ) + assert url_fc == expected_fc + assert url_fc.index("/content") < url_fc.index("?") + + def test_provider_config_manager_returns_azure_config(self): + from litellm.types.utils import LlmProviders + from litellm.utils import ProviderConfigManager + + config = ProviderConfigManager.get_provider_container_config( + provider=LlmProviders.AZURE + ) + + assert config is not None + assert isinstance(config, AzureContainerConfig) + + def test_proxy_handler_factory_returns_azure_config(self): + from litellm.proxy.container_endpoints.handler_factory import ( + _get_container_provider_config, + ) + + config = _get_container_provider_config("azure") + + assert config is not None + assert isinstance(config, AzureContainerConfig) + + def test_proxy_handler_factory_raises_for_unsupported_provider(self): + from litellm.proxy.container_endpoints.handler_factory import ( + _get_container_provider_config, + ) + + with pytest.raises(ValueError, match="Container API not supported"): + _get_container_provider_config("anthropic") + + +class TestAzureContainerKnownFailureRegressions: + """Regression tests for real production / proxy failures (Azure containers). + + 1. **URL / api-version** — ``get_complete_url`` appends ``?api-version=…`` to the + container base. Naïve ``f\"{api_base}/…\"`` put the query *before* path segments, + e.g. ``…/containers?api-version=v1/cntr_…/files``, which Azure rejects + ("API version not supported" / 404-style routing). + + 2. **Bare resource root** — ``AZURE_API_BASE`` is only the host (no ``?``). The + query appears only after LiteLLM builds the full container base; downstream + transforms must still append ``/cntr_…/files/…`` *before* the query string. + + 3. **File content path** — The worst case in logs was POST/GET logging showing + ``…containers?api-version=v1/cntr_…/files/cfile_…/content``; correct wire shape is + ``…containers/cntr_…/files/cfile_…/content?api-version=v1``. + """ + + def setup_method(self): + self.config = AzureContainerConfig() + + def test_regression_query_never_splits_before_container_segment(self): + """Forbid the broken shape: …/containers?api-version=v1/cntr_…""" + from litellm.types.router import GenericLiteLLMParams + + api_base = ( + "https://my-resource.openai.azure.com/openai/v1/containers" + "?api-version=v1" + ) + cid = "cntr_69d4f27de324819082c54f6aeaab6391056f5dbdf1fe2b02" + fid = "cfile_69d4f283bac0819094bfe7805a4f3ce8" + litellm_params = GenericLiteLLMParams() + headers: dict = {} + + url_fc, _ = self.config.transform_container_file_content_request( + container_id=cid, + file_id=fid, + api_base=api_base, + litellm_params=litellm_params, + headers=headers, + ) + # Exact substring seen in broken logs + assert "containers?api-version=v1/" + cid not in url_fc + assert "containers?api-version=v1/cntr_" not in url_fc + + parsed = urlparse(url_fc) + assert parsed.path == ( + f"/openai/v1/containers/{cid}/files/{fid}/content" + ) + assert parse_qs(parsed.query).get("api-version") == ["v1"] + assert url_fc.index("/content") < url_fc.index("?") + + def test_regression_full_chain_bare_resource_root_like_env(self): + """Mimics AZURE_API_BASE=https://resource.openai.azure.com — no ? in env.""" + from litellm.types.router import GenericLiteLLMParams + + resource_root = "https://my-resource.openai.azure.com" + container_base = self.config.get_complete_url( + api_base=resource_root, + litellm_params={}, + ) + assert "openai.azure.com" in container_base + assert "openai/v1/containers" in container_base or "/openai/containers" in container_base + + cid = "cntr_livepath123" + fid = "cfile_live456" + url_fc, params = self.config.transform_container_file_content_request( + container_id=cid, + file_id=fid, + api_base=container_base, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + assert cid in url_fc + assert fid in url_fc + parsed = urlparse(url_fc) + assert cid in parsed.path + assert "?" not in parsed.path + assert "/content" in parsed.path + assert url_fc.index(cid) < (url_fc.index("?") if "?" in url_fc else len(url_fc)) + assert params == {} + + def test_regression_all_crud_urls_with_azure_style_api_base(self): + """Retrieve, delete, list files, and file content all keep ?api-version last.""" + from litellm.types.router import GenericLiteLLMParams + + api_base = ( + "https://iamkankute-5584-resource.openai.azure.com/openai/v1/containers" + "?api-version=v1" + ) + cid = "cntr_69d4f1c5c6448190930a444af3f84f670b35dc2ee845cd1b" + fid = "cfile_69d4f1c97a1081908d22a9f56268c743" + litellm_params = GenericLiteLLMParams() + headers: dict = {} + + url_r, _ = self.config.transform_container_retrieve_request( + container_id=cid, + api_base=api_base, + litellm_params=litellm_params, + headers=headers, + ) + url_d, _ = self.config.transform_container_delete_request( + container_id=cid, + api_base=api_base, + litellm_params=litellm_params, + headers=headers, + ) + url_lf, _ = self.config.transform_container_file_list_request( + container_id=cid, + api_base=api_base, + litellm_params=litellm_params, + headers=headers, + ) + url_fc, _ = self.config.transform_container_file_content_request( + container_id=cid, + file_id=fid, + api_base=api_base, + litellm_params=litellm_params, + headers=headers, + ) + + for name, u in ( + ("retrieve", url_r), + ("delete", url_d), + ("list_files", url_lf), + ("file_content", url_fc), + ): + assert f"containers?api-version=v1/{cid}" not in u, name + p = urlparse(u) + assert cid in p.path, name + assert "api-version" in p.query or "api-version=v1" in u, name + + assert urlparse(url_fc).path.endswith(f"/{cid}/files/{fid}/content") + + def test_regression_api_base_with_extra_query_params(self): + """Multiple query params must stay at the end after path join.""" + from litellm.types.router import GenericLiteLLMParams + + api_base = ( + "https://my-resource.openai.azure.com/openai/v1/containers" + "?api-version=v1&foo=bar" + ) + cid = "cntr_x" + url_lf, _ = self.config.transform_container_file_list_request( + container_id=cid, + api_base=api_base, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + p = urlparse(url_lf) + assert p.path == f"/openai/v1/containers/{cid}/files" + qs = parse_qs(p.query) + assert qs.get("api-version") == ["v1"] + assert qs.get("foo") == ["bar"] + + def test_regression_proxy_resolves_azure_text_same_as_azure(self): + """Router/proxy treat azure_text like azure for container config.""" + from litellm.proxy.container_endpoints.handler_factory import ( + _get_container_provider_config, + ) + + c1 = _get_container_provider_config("azure") + c2 = _get_container_provider_config("azure_text") + assert type(c1) is type(c2) + assert isinstance(c1, AzureContainerConfig) diff --git a/tests/test_litellm/containers/test_container_api.py b/tests/test_litellm/containers/test_container_api.py index 38308f399d0..ba98bbf13a6 100644 --- a/tests/test_litellm/containers/test_container_api.py +++ b/tests/test_litellm/containers/test_container_api.py @@ -25,6 +25,7 @@ from litellm.containers.main import ( from litellm.main import base_llm_http_handler from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLogging from litellm.llms.openai.containers.transformation import OpenAIContainerConfig +from litellm.responses.utils import ResponsesAPIRequestUtils from litellm.router import Router from litellm.types.containers.main import ( ContainerListResponse, @@ -220,6 +221,76 @@ class TestContainerAPI: assert response.expires_after.minutes == 20 assert response.expires_after.anchor == "last_active_at" + def test_retrieve_container_reencodes_short_managed_id_for_routing(self): + """Short cntr_ IDs must still re-encode output so follow-ups keep router affinity.""" + short_managed_id = ResponsesAPIRequestUtils._build_container_id( + custom_llm_provider="azure", + model_id="router-gpt", + container_id="x", + ) + assert short_managed_id.startswith("cntr_") + assert len(short_managed_id) < 100 + + mock_response = ContainerObject( + id="x", + object="container", + created_at=1747857508, + status="running", + expires_after={"anchor": "last_active_at", "minutes": 20}, + last_active_at=1747857508, + name="Tiny", + ) + + with patch.object( + base_llm_http_handler, + "container_retrieve_handler", + return_value=mock_response, + ) as mock_method: + response = retrieve_container( + container_id=short_managed_id, + custom_llm_provider="openai", + ) + + mock_method.assert_called_once() + assert mock_method.call_args.kwargs["container_id"] == "x" + assert response.id.startswith("cntr_") + decoded = ResponsesAPIRequestUtils._decode_container_id(response.id) + assert decoded.get("response_id") == "x" + assert decoded.get("model_id") == "router-gpt" + assert decoded.get("custom_llm_provider") == "azure" + + def test_delete_container_reencodes_short_managed_id_for_routing(self): + """Same as retrieve: short managed IDs must round-trip encoding on delete result.""" + short_managed_id = ResponsesAPIRequestUtils._build_container_id( + custom_llm_provider="azure", + model_id="router-gpt", + container_id="z", + ) + assert len(short_managed_id) < 100 + + mock_response = DeleteContainerResult( + id="z", + object="container.deleted", + deleted=True, + ) + + with patch.object( + base_llm_http_handler, + "container_delete_handler", + return_value=mock_response, + ) as mock_method: + response = delete_container( + container_id=short_managed_id, + custom_llm_provider="openai", + ) + + mock_method.assert_called_once() + assert mock_method.call_args.kwargs["container_id"] == "z" + assert response.id.startswith("cntr_") + decoded = ResponsesAPIRequestUtils._decode_container_id(response.id) + assert decoded.get("response_id") == "z" + assert decoded.get("model_id") == "router-gpt" + @pytest.mark.asyncio async def test_aretrieve_container_basic(self): """Test basic async container retrieval functionality.""" diff --git a/tests/test_litellm/containers/test_container_utils.py b/tests/test_litellm/containers/test_container_utils.py index 356e1ccda6f..42d7182ec2a 100644 --- a/tests/test_litellm/containers/test_container_utils.py +++ b/tests/test_litellm/containers/test_container_utils.py @@ -8,11 +8,17 @@ sys.path.insert( ) # Adds the parent directory to the system path import litellm -from litellm.containers.utils import ContainerRequestUtils +from litellm.containers.utils import ( + ContainerRequestUtils, + decode_managed_container_id_for_request, +) +from litellm.responses.utils import ResponsesAPIRequestUtils +from litellm.types.router import GenericLiteLLMParams from litellm.llms.openai.containers.transformation import OpenAIContainerConfig from litellm.types.containers.main import ( ContainerCreateOptionalRequestParams, - ContainerListOptionalRequestParams + ContainerListOptionalRequestParams, + DeleteContainerFileResponse, ) @@ -228,3 +234,40 @@ class TestContainerRequestUtils: ) assert result["expires_after"]["minutes"] == 15 + + def test_decode_managed_container_id_returns_provider_container_id(self): + """Managed IDs must decode to the short ID sent on upstream requests.""" + inner = "cntr_69d4ff00deadbeef" + managed = ResponsesAPIRequestUtils._build_container_id( + custom_llm_provider="openai", + model_id=None, + container_id=inner, + ) + assert len(managed) > len(inner) + litellm_params: GenericLiteLLMParams = GenericLiteLLMParams() + original_id, provider, updated = decode_managed_container_id_for_request( + managed, "openai", litellm_params + ) + assert original_id == inner + assert provider == "openai" + assert updated is litellm_params + + +class TestDeleteContainerFileResponseWireFormat: + """OpenAI / Azure return ``container.file.deleted`` on DELETE file.""" + + def test_accepts_openai_dot_notation(self): + m = DeleteContainerFileResponse( + id="cfile_abc", + object="container.file.deleted", + deleted=True, + ) + assert m.object == "container.file.deleted" + + def test_accepts_legacy_underscore(self): + m = DeleteContainerFileResponse( + id="cfile_abc", + object="container_file.deleted", + deleted=True, + ) + assert m.object == "container_file.deleted" diff --git a/tests/test_litellm/responses/test_responses_utils.py b/tests/test_litellm/responses/test_responses_utils.py index c6f32b6d758..33f354f444f 100644 --- a/tests/test_litellm/responses/test_responses_utils.py +++ b/tests/test_litellm/responses/test_responses_utils.py @@ -138,6 +138,35 @@ class TestResponsesAPIRequestUtils: assert decoded.get("model_id") == "gpt-4o" assert decoded.get("custom_llm_provider") == "openai" + def test_build_decode_container_id_omits_none_model_id(self): + """model_id=None must not round-trip as the truthy string 'None'.""" + encoded = ResponsesAPIRequestUtils._build_container_id( + custom_llm_provider="azure", + model_id=None, + container_id="cntr_upstream_abc", + ) + assert "None" not in base64.b64decode( + encoded.replace("cntr_", "").encode("utf-8") + ).decode("utf-8") + decoded = ResponsesAPIRequestUtils._decode_container_id(encoded) + assert decoded.get("custom_llm_provider") == "azure" + assert decoded.get("model_id") is None + assert decoded.get("response_id") == "cntr_upstream_abc" + + def test_decode_container_id_legacy_literal_none_model_id(self): + """IDs encoded before the None fix should decode without a bogus model_id.""" + legacy_inner = ( + "litellm:custom_llm_provider:azure;model_id:None;container_id:cntr_x" + ) + legacy_id = ( + "cntr_" + + base64.b64encode(legacy_inner.encode("utf-8")).decode("utf-8") + ) + decoded = ResponsesAPIRequestUtils._decode_container_id(legacy_id) + assert decoded.get("model_id") is None + assert decoded.get("custom_llm_provider") == "azure" + assert decoded.get("response_id") == "cntr_x" + class TestResponseAPILoggingUtils: def test_is_response_api_usage_true(self): From c9e4949485291f06b769da78d648876bcf390fcf Mon Sep 17 00:00:00 2001 From: michelligabriele Date: Sat, 11 Apr 2026 18:29:34 +0200 Subject: [PATCH 5/9] fix(logging): preserve proxy key-auth metadata on /v1/messages Langfuse traces (#25448) * fix(logging): preserve proxy key-auth metadata on /v1/messages Langfuse traces update_from_kwargs() overwrites proxy metadata (user_api_key_hash, etc.) with Anthropic's native metadata when both exist. Merge instead of replace. * fix(test): update stale assertion for new metadata merge semantics * test: add explicit conflict-resolution test for metadata merge --- litellm/litellm_core_utils/litellm_logging.py | 10 +++ .../test_litellm_logging.py | 74 ++++++++++++++++++- 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index 7a3547bca2e..e84c1e13a8b 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -613,7 +613,17 @@ class Logging(LiteLLMLoggingBaseClass): base_litellm_params["metadata"] = kwargs["litellm_metadata"].copy() if litellm_params: + # Merge metadata carefully — don't overwrite the merged metadata + # from kwargs/litellm_metadata with the caller's litellm_params metadata. + # e.g. anthropic_messages passes Anthropic's native metadata ({user_id: ...}) + # in litellm_params, which would overwrite proxy key-auth fields. + lp_metadata = litellm_params.pop("metadata", None) base_litellm_params.update(litellm_params) + if lp_metadata and isinstance(lp_metadata, dict): + base_litellm_params.setdefault("metadata", {}) + for k, v in lp_metadata.items(): + if k not in base_litellm_params["metadata"]: + base_litellm_params["metadata"][k] = v self.update_environment_variables( litellm_params=base_litellm_params, diff --git a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py index 9d584446eb5..ddc44cb5059 100644 --- a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py +++ b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py @@ -429,7 +429,7 @@ class TestUpdateFromKwargs: assert logging_obj.litellm_params["litellm_metadata"] == lm_meta def test_caller_litellm_params_win_over_kwargs(self, logging_obj): - """Explicit litellm_params from the caller should override auto-extracted values.""" + """Explicit litellm_params metadata merges into kwargs metadata without overwriting.""" kwargs = {"metadata": {"from_kwargs": True}} logging_obj.update_from_kwargs( @@ -437,7 +437,24 @@ class TestUpdateFromKwargs: litellm_params={"metadata": {"from_caller": True}, "litellm_call_id": "x"}, ) - assert logging_obj.litellm_params["metadata"] == {"from_caller": True} + # kwargs metadata is preserved, caller metadata is merged in + assert logging_obj.litellm_params["metadata"] == {"from_kwargs": True, "from_caller": True} + + def test_kwargs_metadata_wins_over_caller_metadata_in_conflict(self, logging_obj): + """kwargs metadata takes precedence; caller litellm_params metadata is merged without overwriting.""" + kwargs = {"metadata": {"from_kwargs": True, "shared_key": "kwargs_value"}} + + logging_obj.update_from_kwargs( + kwargs=kwargs, + litellm_params={"metadata": {"from_caller": True, "shared_key": "caller_value"}, "litellm_call_id": "x"}, + ) + + # kwargs metadata is preserved (shared_key keeps the kwargs value), caller-only keys are added + assert logging_obj.litellm_params["metadata"] == { + "from_kwargs": True, + "from_caller": True, + "shared_key": "kwargs_value", # kwargs wins on conflict + } def test_custom_pricing_detected_via_litellm_metadata(self, logging_obj): """Custom pricing in litellm_metadata.model_info should set custom_pricing flag.""" @@ -2153,6 +2170,59 @@ def test_function_setup_metadata_takes_precedence_over_litellm_metadata(): assert litellm_metadata.get("user_api_key_hash") == "sk-hashed-xyz" +def test_update_from_kwargs_litellm_params_metadata_does_not_overwrite_proxy_fields(): + """ + Test the exact bug: when update_from_kwargs is called with litellm_params + containing a 'metadata' key (e.g. Anthropic's native metadata with user_id), + it must NOT overwrite proxy key-auth fields already merged from litellm_metadata. + + This is the anthropic_messages code path where async_anthropic_messages_handler + passes anthropic_messages_optional_request_params (which includes metadata) + as litellm_params to update_from_kwargs. + """ + from litellm.litellm_core_utils.litellm_logging import Logging + + logging_obj = Logging( + model="claude-3-5-sonnet", + messages=[{"role": "user", "content": "test"}], + stream=False, + call_type="anthropic_messages", + start_time=time.time(), + litellm_call_id="test-overwrite-bug", + function_id="test-function-id", + ) + + kwargs = { + "litellm_metadata": { + "user_api_key_hash": "sk-hashed-proxy", + "user_api_key_alias": "claude-api", + "user_api_key_team_id": "team-zurich", + }, + } + + # Simulate what async_anthropic_messages_handler does: + # passes Anthropic's native metadata in litellm_params + logging_obj.update_from_kwargs( + kwargs=kwargs, + litellm_params={ + "preset_cache_key": None, + "stream_response": {}, + "metadata": {"user_id": "anthropic-device-id"}, # Anthropic native metadata + }, + ) + + litellm_params = logging_obj.model_call_details.get("litellm_params", {}) + metadata = litellm_params.get("metadata") + + assert metadata is not None + # Proxy key-auth fields must survive the litellm_params.update() + assert metadata.get("user_api_key_hash") == "sk-hashed-proxy" + assert metadata.get("user_api_key_alias") == "claude-api" + assert metadata.get("user_api_key_team_id") == "team-zurich" + # Anthropic native metadata must also be present + assert metadata.get("user_id") == "anthropic-device-id" + + def test_function_setup_empty_metadata_falls_back_to_litellm_metadata(): """ Test that when metadata is explicitly set to {} (empty dict), litellm_metadata From 7d2f0693616d9946c38266d7908a3df76364b417 Mon Sep 17 00:00:00 2001 From: Josh <36064836+J-Byron@users.noreply.github.com> Date: Sat, 11 Apr 2026 12:34:45 -0400 Subject: [PATCH 6/9] Reduce default latency histogram bucket cardinality (#25527) * feat(prometheus): reduce default latency bucket cardinality and make configurable * test(prometheus): add coverage for PrometheusServicesLogger latency buckets * Revert "test(prometheus): add coverage for PrometheusServicesLogger latency buckets" This reverts commit 1bfd004ad1797e212dfd9d1de502810f81a056a1. * test(prometheus): add coverage for PrometheusServicesLogger latency buckets --- litellm/__init__.py | 1 + litellm/integrations/prometheus.py | 17 +++++--- litellm/integrations/prometheus_services.py | 8 +++- litellm/types/integrations/prometheus.py | 26 ++----------- .../integrations/test_prometheus_services.py | 36 +++++++++++++++++ .../test_prometheus_user_team_metrics.py | 39 +++++++++++++++++++ 6 files changed, 98 insertions(+), 29 deletions(-) diff --git a/litellm/__init__.py b/litellm/__init__.py index 565b86f818d..8087e3f5311 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -164,6 +164,7 @@ initialized_langfuse_clients: int = 0 langfuse_default_tags: Optional[List[str]] = None langsmith_batch_size: Optional[int] = None prometheus_initialize_budget_metrics: Optional[bool] = False +prometheus_latency_buckets: Optional[List[float]] = None require_auth_for_metrics_endpoint: Optional[bool] = False argilla_batch_size: Optional[int] = None datadog_use_v1: Optional[bool] = False # if you want to use v1 datadog logged payload. diff --git a/litellm/integrations/prometheus.py b/litellm/integrations/prometheus.py index c395987695b..b3bf792e93b 100644 --- a/litellm/integrations/prometheus.py +++ b/litellm/integrations/prometheus.py @@ -86,6 +86,11 @@ class PrometheusLogger(CustomLogger): # Always initialize label_filters, even for non-premium users self.label_filters = self._parse_prometheus_config() + _custom_buckets = litellm.prometheus_latency_buckets + self.latency_buckets = ( + tuple(_custom_buckets) if _custom_buckets is not None else LATENCY_BUCKETS + ) + # Create metric factory functions self._counter_factory = self._create_metric_factory(Counter) self._gauge_factory = self._create_metric_factory(Gauge) @@ -114,14 +119,14 @@ class PrometheusLogger(CustomLogger): labelnames=self.get_labels_for_metric( "litellm_request_total_latency_metric" ), - buckets=LATENCY_BUCKETS, + buckets=self.latency_buckets, ) self.litellm_llm_api_latency_metric = self._histogram_factory( "litellm_llm_api_latency_metric", "Total latency (seconds) for a models LLM API call", labelnames=self.get_labels_for_metric("litellm_llm_api_latency_metric"), - buckets=LATENCY_BUCKETS, + buckets=self.latency_buckets, ) self.litellm_llm_api_time_to_first_token_metric = self._histogram_factory( @@ -137,7 +142,7 @@ class PrometheusLogger(CustomLogger): labelnames=self.get_labels_for_metric( "litellm_llm_api_time_to_first_token_metric" ), - buckets=LATENCY_BUCKETS, + buckets=self.latency_buckets, ) # Counter for spend @@ -314,7 +319,7 @@ class PrometheusLogger(CustomLogger): labelnames=self.get_labels_for_metric( "litellm_overhead_latency_metric" ), - buckets=LATENCY_BUCKETS, + buckets=self.latency_buckets, ) # Request queue time metric @@ -324,7 +329,7 @@ class PrometheusLogger(CustomLogger): labelnames=self.get_labels_for_metric( "litellm_request_queue_time_seconds" ), - buckets=LATENCY_BUCKETS, + buckets=self.latency_buckets, ) # Guardrail metrics @@ -332,7 +337,7 @@ class PrometheusLogger(CustomLogger): "litellm_guardrail_latency_seconds", "Latency (seconds) for guardrail execution", labelnames=["guardrail_name", "status", "error_type", "hook_type"], - buckets=LATENCY_BUCKETS, + buckets=self.latency_buckets, ) self.litellm_guardrail_errors_total = self._counter_factory( diff --git a/litellm/integrations/prometheus_services.py b/litellm/integrations/prometheus_services.py index 55ce758ece6..6d549470613 100644 --- a/litellm/integrations/prometheus_services.py +++ b/litellm/integrations/prometheus_services.py @@ -5,6 +5,7 @@ from typing import Dict, List, Optional, Union +import litellm from litellm._logging import print_verbose, verbose_logger from litellm.types.integrations.prometheus import LATENCY_BUCKETS from litellm.types.services import ( @@ -35,6 +36,11 @@ class PrometheusServicesLogger: "Missing prometheus_client. Run `pip install prometheus-client`" ) + _custom_buckets = litellm.prometheus_latency_buckets + self.latency_buckets = ( + tuple(_custom_buckets) if _custom_buckets is not None else LATENCY_BUCKETS + ) + self.Histogram = Histogram self.Counter = Counter self.Gauge = Gauge @@ -130,7 +136,7 @@ class PrometheusServicesLogger: metric_name, "Latency for {} service".format(service), labelnames=[service], - buckets=LATENCY_BUCKETS, + buckets=self.latency_buckets, ) def create_gauge(self, service: str, type_of_request: str): diff --git a/litellm/types/integrations/prometheus.py b/litellm/types/integrations/prometheus.py index 5f1aa9fb2ce..51a41f97e03 100644 --- a/litellm/types/integrations/prometheus.py +++ b/litellm/types/integrations/prometheus.py @@ -122,40 +122,22 @@ STATUS_CODE = "status_code" EXCEPTION_LABELS = [EXCEPTION_STATUS, EXCEPTION_CLASS] LATENCY_BUCKETS = ( 0.005, - 0.00625, - 0.0125, + 0.01, 0.025, 0.05, 0.1, + 0.25, 0.5, 1.0, - 1.5, 2.0, - 2.5, - 3.0, - 3.5, - 4.0, - 4.5, 5.0, - 5.5, - 6.0, - 6.5, - 7.0, - 7.5, - 8.0, - 8.5, - 9.0, - 9.5, 10.0, - 15.0, - 20.0, - 25.0, 30.0, 60.0, 120.0, - 180.0, - 240.0, 300.0, + 420.0, # 7 minutes + 600.0, # 10 minutes (typical default LLM request timeout) float("inf"), ) diff --git a/tests/test_litellm/integrations/test_prometheus_services.py b/tests/test_litellm/integrations/test_prometheus_services.py index ff80d7d9f8b..6e9ab143d3e 100644 --- a/tests/test_litellm/integrations/test_prometheus_services.py +++ b/tests/test_litellm/integrations/test_prometheus_services.py @@ -104,3 +104,39 @@ def test_update_gauge(): # Verify correct methods were called mock_labels.assert_called_once_with("test_label") mock_gauge.set.assert_called_once_with(42.5) + + +def test_services_logger_default_latency_buckets(): + """PrometheusServicesLogger uses the new reduced default latency buckets.""" + from litellm.types.integrations.prometheus import LATENCY_BUCKETS + + pl = PrometheusServicesLogger() + assert pl.latency_buckets == LATENCY_BUCKETS + assert 420.0 in pl.latency_buckets + assert 600.0 in pl.latency_buckets + assert 1.5 not in pl.latency_buckets + + +def test_services_logger_custom_latency_buckets(): + """prometheus_latency_buckets setting is respected by PrometheusServicesLogger.""" + import litellm + from prometheus_client import REGISTRY + + custom_buckets = [0.1, 0.5, 1.0, 5.0, 10.0] + original = litellm.prometheus_latency_buckets + for collector in list(REGISTRY._collector_to_names.keys()): + try: + REGISTRY.unregister(collector) + except Exception: + pass + try: + litellm.prometheus_latency_buckets = custom_buckets + pl = PrometheusServicesLogger() + assert pl.latency_buckets == tuple(custom_buckets) + finally: + litellm.prometheus_latency_buckets = original + for collector in list(REGISTRY._collector_to_names.keys()): + try: + REGISTRY.unregister(collector) + except Exception: + pass diff --git a/tests/test_litellm/integrations/test_prometheus_user_team_metrics.py b/tests/test_litellm/integrations/test_prometheus_user_team_metrics.py index 6b65f444046..e056284ed38 100644 --- a/tests/test_litellm/integrations/test_prometheus_user_team_metrics.py +++ b/tests/test_litellm/integrations/test_prometheus_user_team_metrics.py @@ -768,3 +768,42 @@ async def test_initialize_org_budget_metrics(prometheus_logger): prometheus_logger.litellm_org_max_budget_metric.labels().set.assert_called_once_with( 500.0 ) + + +def test_default_latency_buckets(prometheus_logger): + """PrometheusLogger uses the new reduced default latency buckets.""" + from litellm.types.integrations.prometheus import LATENCY_BUCKETS + + assert prometheus_logger.latency_buckets == LATENCY_BUCKETS + # 420 and 600 should be present + assert 420.0 in prometheus_logger.latency_buckets + assert 600.0 in prometheus_logger.latency_buckets + # dense half-second buckets from old defaults should be gone + assert 1.5 not in prometheus_logger.latency_buckets + assert 9.5 not in prometheus_logger.latency_buckets + + +def test_custom_latency_buckets(): + """prometheus_latency_buckets in litellm settings overrides the defaults.""" + import litellm + from prometheus_client import REGISTRY + + custom_buckets = [0.1, 0.5, 1.0, 5.0, 10.0] + original = litellm.prometheus_latency_buckets + # Clear registry before creating a new PrometheusLogger + for collector in list(REGISTRY._collector_to_names.keys()): + try: + REGISTRY.unregister(collector) + except Exception: + pass + try: + litellm.prometheus_latency_buckets = custom_buckets + logger = PrometheusLogger() + assert logger.latency_buckets == tuple(custom_buckets) + finally: + litellm.prometheus_latency_buckets = original + for collector in list(REGISTRY._collector_to_names.keys()): + try: + REGISTRY.unregister(collector) + except Exception: + pass From 2fe615b37346d8bcba8a3900110a1ccf8575b121 Mon Sep 17 00:00:00 2001 From: jimmychen-p72 Date: Sat, 11 Apr 2026 12:39:12 -0400 Subject: [PATCH 7/9] fix(s3): add retry with exponential backoff for transient S3 503/500 errors (#25530) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(s3): add retry with exponential backoff for transient S3 503/500 errors S3 occasionally returns 503 "Slow Down" during PUT operations when request rates spike above partition limits. The current code makes a single upload attempt via httpx — unlike boto3, httpx has no built-in retry for transient S3 errors. Failed uploads permanently lose the request's audit/logging data. Add exponential backoff retry (3 attempts, 1s/2s delays) for S3 500/503 responses in both async_upload_data_to_s3 and upload_data_to_s3. Logs a warning on each retry with the S3 object key for observability. In production we observed ~18 permanent S3 upload failures per day (124 over 7 days) — all transient 503s that would have succeeded on a single retry. * test(s3): add unit tests for S3 upload retry logic Tests cover: - Async retry on 503 (succeeds on second attempt) - Async retry on 500 - Exhausted retries on persistent 503 (calls handle_callback_failure) - No retry on 4xx errors (403) - Sync retry on 503 * style(s3): move time import to module level Address review feedback: move `import time` from inside upload_data_to_s3 to the top-level imports per project style guide. --- litellm/integrations/s3_v2.py | 43 +++- tests/test_litellm/integrations/test_s3_v2.py | 203 ++++++++++++++++++ 2 files changed, 238 insertions(+), 8 deletions(-) diff --git a/litellm/integrations/s3_v2.py b/litellm/integrations/s3_v2.py index 405bf9698cc..f764b07941b 100644 --- a/litellm/integrations/s3_v2.py +++ b/litellm/integrations/s3_v2.py @@ -7,6 +7,7 @@ NOTE 1: S3 does not provide a BATCH PUT API endpoint, so we create tasks to uplo """ import asyncio +import time from datetime import datetime from typing import List, Optional, cast @@ -403,11 +404,23 @@ class S3Logger(CustomBatchLogger, BaseAWSLLM): # Prepare the signed headers signed_headers = dict(aws_request.headers.items()) - # Make the request - response = await self.async_httpx_client.put( - url, data=json_string, headers=signed_headers - ) - response.raise_for_status() + # Make the request with retry for transient S3 errors (500/503) + max_retries = 3 + for attempt in range(max_retries): + response = await self.async_httpx_client.put( + url, data=json_string, headers=signed_headers + ) + if response.status_code in (500, 503) and attempt < max_retries - 1: + wait_time = 2**attempt # 1s, 2s + verbose_logger.warning( + f"S3 upload returned {response.status_code}, retrying in {wait_time}s " + f"(attempt {attempt + 1}/{max_retries}) " + f"key={batch_logging_element.s3_object_key}" + ) + await asyncio.sleep(wait_time) + continue + response.raise_for_status() + break except Exception as e: verbose_logger.exception(f"Error uploading to s3: {str(e)}") self.handle_callback_failure(callback_name="S3Logger") @@ -582,9 +595,23 @@ class S3Logger(CustomBatchLogger, BaseAWSLLM): if self.s3_verify is not None else None ) - # Make the request - response = httpx_client.put(url, data=json_string, headers=signed_headers) - response.raise_for_status() + # Make the request with retry for transient S3 errors (500/503) + max_retries = 3 + for attempt in range(max_retries): + response = httpx_client.put( + url, data=json_string, headers=signed_headers + ) + if response.status_code in (500, 503) and attempt < max_retries - 1: + wait_time = 2**attempt # 1s, 2s + verbose_logger.warning( + f"S3 upload returned {response.status_code}, retrying in {wait_time}s " + f"(attempt {attempt + 1}/{max_retries}) " + f"key={batch_logging_element.s3_object_key}" + ) + time.sleep(wait_time) + continue + response.raise_for_status() + break except Exception as e: verbose_logger.exception(f"Error uploading to s3: {str(e)}") self.handle_callback_failure(callback_name="S3Logger") diff --git a/tests/test_litellm/integrations/test_s3_v2.py b/tests/test_litellm/integrations/test_s3_v2.py index b53c05fa241..ab4ac1aa68a 100644 --- a/tests/test_litellm/integrations/test_s3_v2.py +++ b/tests/test_litellm/integrations/test_s3_v2.py @@ -292,6 +292,209 @@ class TestS3V2UnitTests: assert result == {"downloaded": "data"} +@pytest.mark.asyncio +async def test_async_upload_retries_on_s3_503(): + """ + Test that async_upload_data_to_s3 retries on transient S3 503 Slow Down + and succeeds on the second attempt. + """ + from unittest.mock import AsyncMock, MagicMock + + from litellm.types.integrations.s3_v2 import s3BatchLoggingElement + + logger = S3Logger( + s3_bucket_name="test-bucket", + s3_aws_access_key_id="test-key", + s3_aws_secret_access_key="test-secret", + s3_region_name="us-east-1", + ) + + test_element = s3BatchLoggingElement( + s3_object_key="2025-09-14/test-retry.json", + payload={"test": "retry"}, + s3_object_download_filename="test-retry.json", + ) + + # First call returns 503, second call returns 200 + response_503 = MagicMock() + response_503.status_code = 503 + response_200 = MagicMock() + response_200.status_code = 200 + response_200.raise_for_status = MagicMock() + + logger.async_httpx_client = AsyncMock() + logger.async_httpx_client.put = AsyncMock(side_effect=[response_503, response_200]) + + with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep: + await logger.async_upload_data_to_s3(test_element) + + # Verify PUT was called twice (retry after 503) + assert logger.async_httpx_client.put.call_count == 2 + # Verify sleep was called with the backoff delay + mock_sleep.assert_called_once_with(1) # 2**0 = 1s + + +@pytest.mark.asyncio +async def test_async_upload_retries_on_s3_500(): + """ + Test that async_upload_data_to_s3 retries on transient S3 500 errors. + """ + from unittest.mock import AsyncMock, MagicMock + + from litellm.types.integrations.s3_v2 import s3BatchLoggingElement + + logger = S3Logger( + s3_bucket_name="test-bucket", + s3_aws_access_key_id="test-key", + s3_aws_secret_access_key="test-secret", + s3_region_name="us-east-1", + ) + + test_element = s3BatchLoggingElement( + s3_object_key="2025-09-14/test-retry-500.json", + payload={"test": "retry-500"}, + s3_object_download_filename="test-retry-500.json", + ) + + response_500 = MagicMock() + response_500.status_code = 500 + response_200 = MagicMock() + response_200.status_code = 200 + response_200.raise_for_status = MagicMock() + + logger.async_httpx_client = AsyncMock() + logger.async_httpx_client.put = AsyncMock(side_effect=[response_500, response_200]) + + with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep: + await logger.async_upload_data_to_s3(test_element) + + assert logger.async_httpx_client.put.call_count == 2 + mock_sleep.assert_called_once_with(1) + + +@pytest.mark.asyncio +async def test_async_upload_exhausts_retries_on_persistent_503(): + """ + Test that async_upload_data_to_s3 raises after exhausting all retries + on persistent S3 503. + """ + from unittest.mock import AsyncMock, MagicMock + + from litellm.types.integrations.s3_v2 import s3BatchLoggingElement + + logger = S3Logger( + s3_bucket_name="test-bucket", + s3_aws_access_key_id="test-key", + s3_aws_secret_access_key="test-secret", + s3_region_name="us-east-1", + ) + + test_element = s3BatchLoggingElement( + s3_object_key="2025-09-14/test-exhaust.json", + payload={"test": "exhaust"}, + s3_object_download_filename="test-exhaust.json", + ) + + # All 3 attempts return 503 + response_503 = MagicMock() + response_503.status_code = 503 + response_503.raise_for_status = MagicMock( + side_effect=Exception("503 Service Unavailable") + ) + + logger.async_httpx_client = AsyncMock() + logger.async_httpx_client.put = AsyncMock(return_value=response_503) + + with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep: + with patch.object(logger, "handle_callback_failure") as mock_failure: + await logger.async_upload_data_to_s3(test_element) + + # 3 PUT attempts total + assert logger.async_httpx_client.put.call_count == 3 + # 2 sleeps (between attempts 1-2 and 2-3) + assert mock_sleep.call_count == 2 + # Callback failure handler called after exhausting retries + mock_failure.assert_called_once_with(callback_name="S3Logger") + + +@pytest.mark.asyncio +async def test_async_upload_no_retry_on_4xx(): + """ + Test that async_upload_data_to_s3 does NOT retry on 4xx errors (client errors). + """ + from unittest.mock import AsyncMock, MagicMock + + from litellm.types.integrations.s3_v2 import s3BatchLoggingElement + + logger = S3Logger( + s3_bucket_name="test-bucket", + s3_aws_access_key_id="test-key", + s3_aws_secret_access_key="test-secret", + s3_region_name="us-east-1", + ) + + test_element = s3BatchLoggingElement( + s3_object_key="2025-09-14/test-no-retry.json", + payload={"test": "no-retry"}, + s3_object_download_filename="test-no-retry.json", + ) + + response_403 = MagicMock() + response_403.status_code = 403 + response_403.raise_for_status = MagicMock(side_effect=Exception("403 Forbidden")) + + logger.async_httpx_client = AsyncMock() + logger.async_httpx_client.put = AsyncMock(return_value=response_403) + + with patch.object(logger, "handle_callback_failure") as mock_failure: + await logger.async_upload_data_to_s3(test_element) + + # Only 1 attempt — no retry for 4xx + assert logger.async_httpx_client.put.call_count == 1 + mock_failure.assert_called_once_with(callback_name="S3Logger") + + +def test_sync_upload_retries_on_s3_503(): + """ + Test that the sync upload_data_to_s3 retries on transient S3 503. + """ + from unittest.mock import MagicMock + + from litellm.types.integrations.s3_v2 import s3BatchLoggingElement + + logger = S3Logger( + s3_bucket_name="test-bucket", + s3_aws_access_key_id="test-key", + s3_aws_secret_access_key="test-secret", + s3_region_name="us-east-1", + ) + + test_element = s3BatchLoggingElement( + s3_object_key="2025-09-14/test-sync-retry.json", + payload={"test": "sync-retry"}, + s3_object_download_filename="test-sync-retry.json", + ) + + response_503 = MagicMock() + response_503.status_code = 503 + response_200 = MagicMock() + response_200.status_code = 200 + response_200.raise_for_status = MagicMock() + + mock_sync_client = MagicMock() + mock_sync_client.put = MagicMock(side_effect=[response_503, response_200]) + + with patch( + "litellm.integrations.s3_v2._get_httpx_client", + return_value=mock_sync_client, + ): + with patch("time.sleep") as mock_sleep: + logger.upload_data_to_s3(test_element) + + assert mock_sync_client.put.call_count == 2 + mock_sleep.assert_called_once_with(1) + + @pytest.mark.asyncio async def test_async_log_event_skips_when_standard_logging_object_missing(): """ From 363f9fe5da3a338abca045842a1bbfd548820653 Mon Sep 17 00:00:00 2001 From: michelligabriele Date: Sat, 11 Apr 2026 18:40:39 +0200 Subject: [PATCH 8/9] fix(proxy): preserve dict guardrail HTTPException.detail + bedrock context (#25558) --- litellm/proxy/common_request_processing.py | 67 ++++++- .../guardrail_hooks/bedrock_guardrails.py | 159 +++++++++++++++- litellm/proxy/utils.py | 124 +++++++++--- .../test_bedrock_guardrails.py | 152 ++++++++++++++- .../proxy/test_common_request_processing.py | 179 +++++++++++++++++- tests/test_litellm/proxy/test_proxy_utils.py | 76 ++++++++ 6 files changed, 716 insertions(+), 41 deletions(-) diff --git a/litellm/proxy/common_request_processing.py b/litellm/proxy/common_request_processing.py index 09200c96841..037f913ad07 100644 --- a/litellm/proxy/common_request_processing.py +++ b/litellm/proxy/common_request_processing.py @@ -9,6 +9,7 @@ from typing import ( Any, AsyncGenerator, Callable, + Dict, Literal, Optional, Tuple, @@ -65,6 +66,37 @@ from litellm.proxy.litellm_pre_call_utils import add_litellm_data_to_request from litellm.types.utils import ModelResponse, ModelResponseStream, Usage +def _serialize_http_exception_detail( + detail: Any, +) -> Tuple[str, Optional[dict]]: + """ + Convert an HTTPException.detail value into (message, structured_fields) + for ProxyException / SSE error frames. + + Dict-detail HTTPExceptions raised by guardrails were previously str()-mangled + into a Python repr blob, producing unparseable error responses on both the + streaming and non-streaming proxy surfaces. This helper extracts a clean + human-readable message while preserving the full payload as structured + fields, so the dominant guardrail shapes (`{"error": "..."}` flat and + `{"error": {"message": "..."}}` nested) both round-trip cleanly. + """ + if isinstance(detail, str): + return detail, None + if isinstance(detail, dict): + err = detail.get("error") + if isinstance(err, str): + return err, detail + if isinstance(err, dict): + nested_msg = err.get("message") + if isinstance(nested_msg, str): + return nested_msg, detail + msg = detail.get("message") + if isinstance(msg, str): + return msg, detail + return json.dumps(detail), detail + return str(detail), None + + async def _parse_event_data_for_error(event_line: Union[str, bytes]) -> Optional[int]: """Parses an event line and returns an error code if present, else None.""" event_line = ( @@ -223,12 +255,28 @@ async def create_response( # Preserve status code from HTTPException (e.g., guardrail blocks) error_status = getattr(e, "status_code", status.HTTP_500_INTERNAL_SERVER_ERROR) - error_detail = getattr(e, "detail", "Error processing stream start") - if not isinstance(error_detail, str): - error_detail = str(error_detail) + raw_detail = getattr(e, "detail", "Error processing stream start") + message, structured_fields = _serialize_http_exception_detail(raw_detail) + + existing_fields = getattr(e, "provider_specific_fields", None) or {} + if structured_fields: + merged_fields: Optional[dict] = {**existing_fields, **structured_fields} + else: + merged_fields = existing_fields or None + + # Match ProxyException.to_dict() shape so streaming and non-streaming + # error frames are byte-identical. + error_obj: Dict[str, Any] = { + "message": message, + "type": getattr(e, "type", "None"), + "param": getattr(e, "param", "None"), + "code": str(error_status), + } + if merged_fields: + error_obj["provider_specific_fields"] = merged_fields async def error_gen_message() -> AsyncGenerator[str, None]: - yield f"data: {json.dumps({'error': {'message': error_detail, 'code': error_status}})}\n\n" + yield f"data: {json.dumps({'error': error_obj})}\n\n" yield "data: [DONE]\n\n" return StreamingResponse( @@ -1593,12 +1641,19 @@ class ProxyBaseLLMRequestProcessing: pass if isinstance(e, HTTPException): + raw_detail = getattr(e, "detail", str(e)) + message, structured_fields = _serialize_http_exception_detail(raw_detail) + existing_fields = getattr(e, "provider_specific_fields", None) or {} + if structured_fields: + merged_fields: Optional[dict] = {**existing_fields, **structured_fields} + else: + merged_fields = existing_fields or None raise ProxyException( - message=getattr(e, "detail", str(e)), + message=message, type=getattr(e, "type", "None"), param=getattr(e, "param", "None"), code=getattr(e, "status_code", status.HTTP_400_BAD_REQUEST), - provider_specific_fields=getattr(e, "provider_specific_fields", None), + provider_specific_fields=merged_fields, headers=headers, ) elif isinstance(e, httpx.HTTPStatusError): diff --git a/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py b/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py index 8ef188bb23c..067d3a007f2 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py +++ b/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py @@ -18,6 +18,7 @@ from typing import ( TYPE_CHECKING, Any, AsyncGenerator, + Dict, List, Literal, NamedTuple, @@ -636,6 +637,141 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): return (status_code, err) return (status_code, message) + def _extract_blocked_assessments( + self, response: BedrockGuardrailResponse + ) -> List[dict]: + """ + Walk the Bedrock guardrail response and emit a structured list of + BLOCKED assessment entries describing exactly which policies fired. + + Mirrors the iteration in `_should_raise_guardrail_blocked_exception()` + but produces a list of `{policy, matches}` dicts instead of a bool. + Each `match` carries the originating subcategory, type, action, and + matched term where available, so the client can render a precise + explanation of the violation. + """ + blocked: List[dict] = [] + assessments = response.get("assessments", []) or [] + + for assessment in assessments: + # Topic policy + topic_policy = assessment.get("topicPolicy") + if topic_policy: + topic_matches = [ + { + "category": "topics", + "name": t.get("name"), + "type": t.get("type"), + "action": t.get("action"), + } + for t in (topic_policy.get("topics") or []) + if t.get("action") == "BLOCKED" + ] + if topic_matches: + blocked.append({"policy": "topicPolicy", "matches": topic_matches}) + + # Content policy + content_policy = assessment.get("contentPolicy") + if content_policy: + content_matches = [ + { + "category": "filters", + "type": f.get("type"), + "confidence": f.get("confidence"), + "filterStrength": f.get("filterStrength"), + "action": f.get("action"), + } + for f in (content_policy.get("filters") or []) + if f.get("action") == "BLOCKED" + ] + if content_matches: + blocked.append( + {"policy": "contentPolicy", "matches": content_matches} + ) + + # Word policy + word_policy = assessment.get("wordPolicy") + if word_policy: + word_matches: List[dict] = [] + for w in word_policy.get("customWords") or []: + if w.get("action") == "BLOCKED": + word_matches.append( + { + "category": "customWords", + "match": w.get("match"), + "action": w.get("action"), + } + ) + for w in word_policy.get("managedWordLists") or []: + if w.get("action") == "BLOCKED": + word_matches.append( + { + "category": "managedWordLists", + "type": w.get("type"), + "match": w.get("match"), + "action": w.get("action"), + } + ) + if word_matches: + blocked.append({"policy": "wordPolicy", "matches": word_matches}) + + # Sensitive information policy (PII) + sensitive_info = assessment.get("sensitiveInformationPolicy") + if sensitive_info: + pii_matches: List[dict] = [] + for p in sensitive_info.get("piiEntities") or []: + if p.get("action") == "BLOCKED": + pii_matches.append( + { + "category": "piiEntities", + "type": p.get("type"), + "match": p.get("match"), + "action": p.get("action"), + } + ) + for r in sensitive_info.get("regexes") or []: + if r.get("action") == "BLOCKED": + pii_matches.append( + { + "category": "regexes", + "name": r.get("name"), + "regex": r.get("regex"), + "match": r.get("match"), + "action": r.get("action"), + } + ) + if pii_matches: + blocked.append( + { + "policy": "sensitiveInformationPolicy", + "matches": pii_matches, + } + ) + + # Contextual grounding policy + contextual = assessment.get("contextualGroundingPolicy") + if contextual: + grounding_matches = [ + { + "category": "filters", + "type": f.get("type"), + "threshold": f.get("threshold"), + "score": f.get("score"), + "action": f.get("action"), + } + for f in (contextual.get("filters") or []) + if f.get("action") == "BLOCKED" + ] + if grounding_matches: + blocked.append( + { + "policy": "contextualGroundingPolicy", + "matches": grounding_matches, + } + ) + + return blocked + def _get_http_exception_for_blocked_guardrail( self, response: BedrockGuardrailResponse ) -> Union[HTTPException, GuardrailInterventionNormalStringError]: @@ -655,14 +791,21 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): return GuardrailInterventionNormalStringError( message=bedrock_guardrail_output_text ) - else: - return HTTPException( - status_code=400, - detail={ - "error": "Violated guardrail policy", - "bedrock_guardrail_response": bedrock_guardrail_output_text, - }, - ) + + detail: Dict[str, Any] = { + "error": "Violated guardrail policy", + "bedrock_guardrail_response": bedrock_guardrail_output_text, + } + if self.guardrailIdentifier: + detail["guardrailIdentifier"] = self.guardrailIdentifier + if self.guardrailVersion: + detail["guardrailVersion"] = self.guardrailVersion + + assessments = self._extract_blocked_assessments(response) + if assessments: + detail["assessments"] = assessments + + return HTTPException(status_code=400, detail=detail) def _should_raise_guardrail_blocked_exception( self, response: BedrockGuardrailResponse diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py index 88a2e1e95cd..e15b48577de 100644 --- a/litellm/proxy/utils.py +++ b/litellm/proxy/utils.py @@ -15,6 +15,8 @@ from email.mime.text import MIMEText from typing import ( TYPE_CHECKING, Any, + AsyncGenerator, + Awaitable, Dict, List, Literal, @@ -300,6 +302,30 @@ def _accepts_litellm_call_info(cb: CustomLogger) -> bool: return _CALLBACK_ACCEPTS_CALL_INFO[key] +def _enrich_http_exception_with_guardrail_context( + exc: BaseException, callback: Any +) -> None: + """ + If `exc` is an HTTPException with a dict `detail`, mutate it in place to + add `guardrail_name` and `guardrail_mode` taken from the callback instance. + + Uses setdefault so guardrails that already populate these fields explicitly + win over the inferred defaults. No-op for non-HTTPException, non-dict-detail, + or callbacks without `guardrail_name`. Never raises. + """ + if not isinstance(exc, HTTPException): + return + detail = getattr(exc, "detail", None) + if not isinstance(detail, dict): + return + guardrail_name = getattr(callback, "guardrail_name", None) + if guardrail_name: + detail.setdefault("guardrail_name", guardrail_name) + event_hook = getattr(callback, "event_hook", None) + if event_hook: + detail.setdefault("guardrail_mode", event_hook) + + class ProxyLogging: """ Logging/Custom Handlers for proxy. @@ -1063,6 +1089,7 @@ class ProxyLogging: except Exception as e: status = "error" error_type = type(e).__name__ + _enrich_http_exception_with_guardrail_context(e, callback) # Re-raise the exception to maintain existing behavior raise finally: @@ -1431,6 +1458,40 @@ class ProxyLogging: except Exception as e: raise e + @staticmethod + async def _run_guardrail_task_with_enrichment( + callback: Any, coro: Awaitable[Any] + ) -> Any: + """ + Await `coro`; if it raises an HTTPException with dict detail, + enrich the detail with the originating callback's `guardrail_name` + and `guardrail_mode` before re-raising. + """ + try: + return await coro + except Exception as e: + _enrich_http_exception_with_guardrail_context(e, callback) + raise + + @staticmethod + async def _wrap_streaming_iterator_with_enrichment( + callback: Any, gen: AsyncGenerator[Any, None] + ) -> AsyncGenerator[Any, None]: + """ + Yield from `gen`; if iteration raises an HTTPException with dict detail, + enrich the detail with the originating callback's `guardrail_name` and + `guardrail_mode` before re-raising. Used to wrap each layer of the + async_post_call_streaming_iterator_hook chain so the enrichment is + attributed to the callback that produced the chunk pipeline at that + point in the chain. + """ + try: + async for chunk in gen: + yield chunk + except Exception as e: + _enrich_http_exception_with_guardrail_context(e, callback) + raise + async def during_call_hook( self, data: dict, @@ -1481,16 +1542,22 @@ class ProxyLogging: and user_api_key_dict is not None ): data["guardrail_to_apply"] = callback - guardrail_task = unified_guardrail.async_moderation_hook( - user_api_key_dict=user_api_key_dict, - data=data, - call_type=call_type, + guardrail_task = self._run_guardrail_task_with_enrichment( + callback, + unified_guardrail.async_moderation_hook( + user_api_key_dict=user_api_key_dict, + data=data, + call_type=call_type, + ), ) else: - guardrail_task = callback.async_moderation_hook( - data=data, - user_api_key_dict=user_api_key_auth_dict, # type: ignore - call_type=call_type, # type: ignore + guardrail_task = self._run_guardrail_task_with_enrichment( + callback, + callback.async_moderation_hook( + data=data, + user_api_key_dict=user_api_key_auth_dict, # type: ignore + call_type=call_type, # type: ignore + ), ) guardrail_tasks.append(guardrail_task) @@ -1985,19 +2052,27 @@ class ProxyLogging: if "apply_guardrail" in type(callback).__dict__: data["guardrail_to_apply"] = callback - guardrail_response = ( - await unified_guardrail.async_post_call_success_hook( + try: + guardrail_response = ( + await unified_guardrail.async_post_call_success_hook( + user_api_key_dict=user_api_key_dict, + data=data, + response=response, + ) + ) + except Exception as e: + _enrich_http_exception_with_guardrail_context(e, callback) + raise + else: + try: + guardrail_response = await callback.async_post_call_success_hook( user_api_key_dict=user_api_key_dict, data=data, response=response, ) - ) - else: - guardrail_response = await callback.async_post_call_success_hook( - user_api_key_dict=user_api_key_dict, - data=data, - response=response, - ) + except Exception as e: + _enrich_http_exception_with_guardrail_context(e, callback) + raise if guardrail_response is not None: response = guardrail_response @@ -2206,29 +2281,32 @@ class ProxyLogging: "async_post_call_streaming_iterator_hook" in type(callback).__dict__ ): - current_response = ( + current_response = self._wrap_streaming_iterator_with_enrichment( + _callback, _callback.async_post_call_streaming_iterator_hook( user_api_key_dict=user_api_key_dict, response=current_response, request_data=request_data, - ) + ), ) elif "apply_guardrail" in type(callback).__dict__: request_data["guardrail_to_apply"] = callback - current_response = ( + current_response = self._wrap_streaming_iterator_with_enrichment( + _callback, unified_guardrail.async_post_call_streaming_iterator_hook( user_api_key_dict=user_api_key_dict, request_data=request_data, response=current_response, - ) + ), ) else: - current_response = ( + current_response = self._wrap_streaming_iterator_with_enrichment( + _callback, _callback.async_post_call_streaming_iterator_hook( user_api_key_dict=user_api_key_dict, response=current_response, request_data=request_data, - ) + ), ) # Actually iterate through the chained async generator and yield chunks diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py index 84d320a0a27..010ead425ca 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py @@ -1186,6 +1186,156 @@ async def test_bedrock_guardrail_blocked_content_with_masking_enabled(): # Verify exception details assert exc_info.value.status_code == 400 assert "Violated guardrail policy" in str(exc_info.value.detail) - + print("✅ BLOCKED content with masking enabled raises exception correctly") + +# --------------------------------------------------------------------------- +# L3: _extract_blocked_assessments + _get_http_exception_for_blocked_guardrail +# Regression coverage for case 2026-04-10-internal-bedrock-guardrail-streaming-error. +# --------------------------------------------------------------------------- + + +def _make_guardrail() -> BedrockGuardrail: + return BedrockGuardrail( + guardrail_name="bedrock-pii-guard", + guardrailIdentifier="amgllac6xf3r", + guardrailVersion="1", + ) + + +def test_extract_blocked_assessments_pii_entity(): + """L3: PII entity match (BLOCKED) is surfaced with category, type, and matched term.""" + g = _make_guardrail() + response = { + "action": "GUARDRAIL_INTERVENED", + "assessments": [ + { + "sensitiveInformationPolicy": { + "piiEntities": [ + {"type": "NAME", "action": "BLOCKED", "match": "Jack"}, + {"type": "EMAIL", "action": "ANONYMIZED", "match": "x@y.z"}, + ] + } + } + ], + } + blocked = g._extract_blocked_assessments(response) + assert len(blocked) == 1 + assert blocked[0]["policy"] == "sensitiveInformationPolicy" + matches = blocked[0]["matches"] + assert len(matches) == 1 # only the BLOCKED one is surfaced + assert matches[0]["category"] == "piiEntities" + assert matches[0]["type"] == "NAME" + assert matches[0]["match"] == "Jack" + + +def test_extract_blocked_assessments_multiple_policies(): + """L3: multiple policies fired in one assessment must all be reported.""" + g = _make_guardrail() + response = { + "action": "GUARDRAIL_INTERVENED", + "assessments": [ + { + "topicPolicy": { + "topics": [ + {"name": "Investment", "type": "DENY", "action": "BLOCKED"} + ] + }, + "contentPolicy": { + "filters": [ + { + "type": "VIOLENCE", + "confidence": "HIGH", + "filterStrength": "HIGH", + "action": "BLOCKED", + } + ] + }, + "wordPolicy": { + "customWords": [{"match": "forbidden", "action": "BLOCKED"}] + }, + } + ], + } + blocked = g._extract_blocked_assessments(response) + policies = {entry["policy"] for entry in blocked} + assert policies == {"topicPolicy", "contentPolicy", "wordPolicy"} + + +def test_extract_blocked_assessments_only_anonymized_returns_empty(): + """L3: if all matches are ANONYMIZED (not BLOCKED), the list is empty.""" + g = _make_guardrail() + response = { + "action": "GUARDRAIL_INTERVENED", + "assessments": [ + { + "sensitiveInformationPolicy": { + "piiEntities": [ + {"type": "NAME", "action": "ANONYMIZED", "match": "Jack"} + ] + } + } + ], + } + assert g._extract_blocked_assessments(response) == [] + + +def test_extract_blocked_assessments_no_assessments(): + """L3: response with no assessments returns an empty list, not an error.""" + g = _make_guardrail() + assert g._extract_blocked_assessments({"action": "NONE"}) == [] + assert g._extract_blocked_assessments({"assessments": None}) == [] + + +def test_get_http_exception_includes_assessments_and_identifier(): + """L3: end-to-end — _get_http_exception_for_blocked_guardrail emits the new fields.""" + g = _make_guardrail() + response = { + "action": "GUARDRAIL_INTERVENED", + "outputs": [{"text": "Sorry, the model cannot answer this question."}], + "assessments": [ + { + "sensitiveInformationPolicy": { + "piiEntities": [ + {"type": "NAME", "action": "BLOCKED", "match": "Jack"} + ] + } + } + ], + } + exc = g._get_http_exception_for_blocked_guardrail(response) + assert isinstance(exc, HTTPException) + assert exc.status_code == 400 + assert exc.detail["error"] == "Violated guardrail policy" + assert ( + exc.detail["bedrock_guardrail_response"] + == "Sorry, the model cannot answer this question." + ) + assert exc.detail["guardrailIdentifier"] == "amgllac6xf3r" + assert exc.detail["guardrailVersion"] == "1" + assert exc.detail["assessments"][0]["policy"] == "sensitiveInformationPolicy" + assert exc.detail["assessments"][0]["matches"][0]["type"] == "NAME" + + +def test_get_http_exception_no_blocked_assessments_omits_field(): + """L3: when no assessments are blocked, the `assessments` key is omitted entirely.""" + g = _make_guardrail() + response = { + "action": "GUARDRAIL_INTERVENED", + "outputs": [{"text": "blocked"}], + "assessments": [ + { + "sensitiveInformationPolicy": { + "piiEntities": [ + {"type": "NAME", "action": "ANONYMIZED", "match": "Jack"} + ] + } + } + ], + } + exc = g._get_http_exception_for_blocked_guardrail(response) + assert isinstance(exc, HTTPException) + assert "assessments" not in exc.detail + assert exc.detail["guardrailIdentifier"] == "amgllac6xf3r" + diff --git a/tests/test_litellm/proxy/test_common_request_processing.py b/tests/test_litellm/proxy/test_common_request_processing.py index 781b559651f..0cc65fe4937 100644 --- a/tests/test_litellm/proxy/test_common_request_processing.py +++ b/tests/test_litellm/proxy/test_common_request_processing.py @@ -886,14 +886,17 @@ class TestCommonRequestProcessingHelpers: response = await create_response(mock_gen, "text/event-stream", {}) assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR content = await self.consume_stream(response) + # Streaming SSE error frame now mirrors ProxyException.to_dict() shape + # so streaming and non-streaming surfaces emit byte-identical errors. expected_error_data = { "error": { "message": "Error processing stream start", - "code": status.HTTP_500_INTERNAL_SERVER_ERROR, + "type": "None", + "param": "None", + "code": str(status.HTTP_500_INTERNAL_SERVER_ERROR), } } assert len(content) == 2 - # Use json.dumps to match the formatting in create_streaming_response's exception handler import json assert content[0] == f"data: {json.dumps(expected_error_data)}\n\n" @@ -919,13 +922,130 @@ class TestCommonRequestProcessingHelpers: expected_error_data = { "error": { "message": "Content blocked by guardrail", - "code": 400, + "type": "None", + "param": "None", + "code": "400", } } assert len(content) == 2 assert content[0] == f"data: {json.dumps(expected_error_data)}\n\n" assert content[1] == "data: [DONE]\n\n" + async def test_create_streaming_response_http_exception_dict_detail_bedrock_shape( + self, + ): + """ + Bedrock-style dict detail (with the post-L3 shape) must be preserved as + structured `provider_specific_fields` in the SSE error frame, not stringified + into a Python-repr blob inside `error.message`. Regression for case + 2026-04-10-internal-bedrock-guardrail-streaming-error. + """ + import json + + mock_gen = AsyncMock() + mock_gen.__anext__.side_effect = HTTPException( + status_code=400, + detail={ + "error": "Violated guardrail policy", + "bedrock_guardrail_response": "Sorry, the model cannot answer this question. Prompt is blocked", + "guardrailIdentifier": "amgllac6xf3r", + "guardrailVersion": "1", + "assessments": [ + { + "policy": "sensitiveInformationPolicy", + "matches": [ + { + "category": "piiEntities", + "type": "NAME", + "action": "BLOCKED", + "match": "Jack", + } + ], + } + ], + "guardrail_name": "bedrock-pii-guard", + "guardrail_mode": "post_call", + }, + ) + + response = await create_response(mock_gen, "text/event-stream", {}) + assert response.status_code == 400 + content = await self.consume_stream(response) + assert len(content) == 2 + assert content[1] == "data: [DONE]\n\n" + + payload = json.loads(content[0][len("data: ") :].strip()) + assert payload["error"]["message"] == "Violated guardrail policy" + assert payload["error"]["code"] == "400" + psf = payload["error"]["provider_specific_fields"] + assert psf["guardrail_name"] == "bedrock-pii-guard" + assert psf["guardrail_mode"] == "post_call" + assert psf["guardrailIdentifier"] == "amgllac6xf3r" + assert psf["assessments"][0]["policy"] == "sensitiveInformationPolicy" + assert psf["assessments"][0]["matches"][0]["type"] == "NAME" + + async def test_create_streaming_response_http_exception_dict_detail_nested_error_shape( + self, + ): + """PANW Prisma AIRS-style nested `{"error": {"message": ...}}` detail must + extract `error.message` as the human-readable summary while preserving the + full payload.""" + import json + + mock_gen = AsyncMock() + mock_gen.__anext__.side_effect = HTTPException( + status_code=400, + detail={ + "error": { + "message": "MCP request blocked: no rewritable argument field present", + "type": "guardrail_violation", + "code": "panw_prisma_airs_blocked", + } + }, + ) + response = await create_response(mock_gen, "text/event-stream", {}) + content = await self.consume_stream(response) + payload = json.loads(content[0][len("data: ") :].strip()) + assert ( + payload["error"]["message"] + == "MCP request blocked: no rewritable argument field present" + ) + assert ( + payload["error"]["provider_specific_fields"]["error"]["code"] + == "panw_prisma_airs_blocked" + ) + + async def test_serialize_http_exception_detail_helper(self): + """Direct unit coverage for the L1 helper across all branches.""" + from litellm.proxy.common_request_processing import ( + _serialize_http_exception_detail, + ) + import json as _json + + assert _serialize_http_exception_detail("plain") == ("plain", None) + + msg, fields = _serialize_http_exception_detail( + {"error": "Violated", "extra": "x"} + ) + assert msg == "Violated" + assert fields == {"error": "Violated", "extra": "x"} + + msg, fields = _serialize_http_exception_detail( + {"error": {"message": "blocked", "code": "x"}} + ) + assert msg == "blocked" + assert fields == {"error": {"message": "blocked", "code": "x"}} + + msg, fields = _serialize_http_exception_detail({"message": "top-level"}) + assert msg == "top-level" + assert fields == {"message": "top-level"} + + msg, fields = _serialize_http_exception_detail({"weird": ["a", "b"]}) + assert msg == _json.dumps({"weird": ["a", "b"]}) + assert fields == {"weird": ["a", "b"]} + + assert _serialize_http_exception_detail(42) == ("42", None) + async def test_create_streaming_response_first_chunk_error_string_code(self): """ Test that when the first chunk contains a string error code, a JSON error response is returned @@ -1853,3 +1973,56 @@ class TestHasAttributeErrorInChain: exc_a.__context__ = exc_b exc_b.__context__ = exc_a # circular assert _has_attribute_error_in_chain(exc_a) is False + + +@pytest.mark.asyncio +class TestHandleLLMApiExceptionDictDetail: + """ + Coverage for `_handle_llm_api_exception` HTTPException branch (Site 2). + Regression for case 2026-04-10-internal-bedrock-guardrail-streaming-error: + dict-detail HTTPExceptions raised by guardrails must round-trip cleanly + through ProxyException instead of being str()-mangled into a Python repr. + """ + + async def _invoke(self, exc: Exception): + from litellm.proxy._types import ProxyException, UserAPIKeyAuth + + processor = ProxyBaseLLMRequestProcessing(data={}) + user_api_key_dict = UserAPIKeyAuth(api_key="sk-test") + proxy_logging_obj = MagicMock() + proxy_logging_obj.post_call_failure_hook = AsyncMock(return_value=None) + proxy_logging_obj.post_call_response_headers_hook = AsyncMock(return_value={}) + + try: + await processor._handle_llm_api_exception( + e=exc, + user_api_key_dict=user_api_key_dict, + proxy_logging_obj=proxy_logging_obj, + ) + except ProxyException as raised: + return raised + raise AssertionError("ProxyException was not raised") + + async def test_dict_detail_bedrock_shape_preserved(self): + exc = HTTPException( + status_code=400, + detail={ + "error": "Violated guardrail policy", + "bedrock_guardrail_response": "...", + "guardrail_name": "bedrock-pii-guard", + }, + ) + proxy_exc = await self._invoke(exc) + assert proxy_exc.message == "Violated guardrail policy" + assert ( + proxy_exc.provider_specific_fields["guardrail_name"] + == "bedrock-pii-guard" + ) + # No Python repr leakage of the dict into the message field. + assert "{'error':" not in proxy_exc.message + + async def test_string_detail_unchanged(self): + exc = HTTPException(status_code=400, detail="Content blocked by guardrail") + proxy_exc = await self._invoke(exc) + assert proxy_exc.message == "Content blocked by guardrail" + assert proxy_exc.provider_specific_fields is None diff --git a/tests/test_litellm/proxy/test_proxy_utils.py b/tests/test_litellm/proxy/test_proxy_utils.py index 4b50e9a4d31..ed7cc98e210 100644 --- a/tests/test_litellm/proxy/test_proxy_utils.py +++ b/tests/test_litellm/proxy/test_proxy_utils.py @@ -190,3 +190,79 @@ def test_get_projected_spend_over_limit_includes_current_spend(monkeypatch): projected_spend, projected_exceeded_date = result assert projected_spend == 290.0 assert projected_exceeded_date == real_datetime.date(2026, 4, 21) + + +# --------------------------------------------------------------------------- +# L2: _enrich_http_exception_with_guardrail_context +# Regression coverage for case 2026-04-10-internal-bedrock-guardrail-streaming-error. +# --------------------------------------------------------------------------- + + +def test_enrich_http_exception_with_guardrail_context_dict_detail(): + """L2: dict-detail HTTPException is enriched with guardrail_name and mode.""" + from litellm.proxy.utils import _enrich_http_exception_with_guardrail_context + + class StubCallback: + guardrail_name = "bedrock-pii-guard" + event_hook = "post_call" + + exc = HTTPException( + status_code=400, detail={"error": "Violated guardrail policy"} + ) + _enrich_http_exception_with_guardrail_context(exc, StubCallback()) + assert exc.detail["guardrail_name"] == "bedrock-pii-guard" + assert exc.detail["guardrail_mode"] == "post_call" + + +def test_enrich_http_exception_string_detail_noop(): + """L2: string-detail HTTPException is not mutated (can't add fields to a str).""" + from litellm.proxy.utils import _enrich_http_exception_with_guardrail_context + + class StubCallback: + guardrail_name = "x" + event_hook = "pre_call" + + exc = HTTPException(status_code=400, detail="Content blocked") + _enrich_http_exception_with_guardrail_context(exc, StubCallback()) + assert exc.detail == "Content blocked" + + +def test_enrich_http_exception_setdefault_does_not_overwrite(): + """L2: a guardrail that already populates guardrail_name explicitly wins.""" + from litellm.proxy.utils import _enrich_http_exception_with_guardrail_context + + class StubCallback: + guardrail_name = "inferred-name" + event_hook = "pre_call" + + exc = HTTPException( + status_code=400, + detail={"error": "x", "guardrail_name": "explicit-name"}, + ) + _enrich_http_exception_with_guardrail_context(exc, StubCallback()) + assert exc.detail["guardrail_name"] == "explicit-name" + + +def test_enrich_http_exception_non_http_exception_noop(): + """L2: non-HTTPException is left alone and the helper does not raise.""" + from litellm.proxy.utils import _enrich_http_exception_with_guardrail_context + + class StubCallback: + guardrail_name = "x" + event_hook = "pre_call" + + exc = ValueError("not an HTTPException") + _enrich_http_exception_with_guardrail_context(exc, StubCallback()) + assert str(exc) == "not an HTTPException" + + +def test_enrich_http_exception_callback_without_guardrail_name_noop(): + """L2: callback without guardrail_name attribute leaves detail alone.""" + from litellm.proxy.utils import _enrich_http_exception_with_guardrail_context + + class StubCallback: + pass + + exc = HTTPException(status_code=400, detail={"error": "x"}) + _enrich_http_exception_with_guardrail_context(exc, StubCallback()) + assert exc.detail == {"error": "x"} From c40e459447d213d6ee672cb7c8d86f783b3e5820 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sat, 11 Apr 2026 15:44:15 -0700 Subject: [PATCH 9/9] fix linting --- .../guardrails/guardrail_hooks/bedrock_guardrails.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py b/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py index 067d3a007f2..b4b8e681133 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py +++ b/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py @@ -702,14 +702,14 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): "action": w.get("action"), } ) - for w in word_policy.get("managedWordLists") or []: - if w.get("action") == "BLOCKED": + for mw in word_policy.get("managedWordLists") or []: + if mw.get("action") == "BLOCKED": word_matches.append( { "category": "managedWordLists", - "type": w.get("type"), - "match": w.get("match"), - "action": w.get("action"), + "type": mw.get("type"), + "match": mw.get("match"), + "action": mw.get("action"), } ) if word_matches: