diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index 9b7707eabe1..01802c3bbb3 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -5762,11 +5762,15 @@ def get_standard_logging_object_payload( response_model_name = final_response_obj.get("model") # For Azure Model Router, preserve the actual model in the top-level standard - # logging payload only when the user has opted in. + # logging payload. + from litellm.llms.azure_ai.common_utils import AzureFoundryModelInfo + requested_model: Final = kwargs.get("model") - if ( - isinstance(requested_model, str) - and ("model_router" in requested_model.lower() or "model-router" in requested_model.lower()) + stamped_selected_model: Final = AzureFoundryModelInfo.get_model_router_selected_model(hidden_params) + if stamped_selected_model is not None: + model_name = stamped_selected_model + elif ( + AzureFoundryModelInfo.is_model_router_call(model=requested_model, hidden_params=hidden_params) and isinstance(response_model_name, str) and response_model_name ): diff --git a/litellm/llms/azure_ai/azure_model_router/transformation.py b/litellm/llms/azure_ai/azure_model_router/transformation.py index 1a924088390..d33564c0f9a 100644 --- a/litellm/llms/azure_ai/azure_model_router/transformation.py +++ b/litellm/llms/azure_ai/azure_model_router/transformation.py @@ -65,15 +65,24 @@ class AzureModelRouterConfig(AzureAIStudioConfig): Extracts the actual model used from the Azure response (e.g., gpt-5-nano-2025-08-07) and returns it with the azure_ai/ prefix for proper display and cost tracking. + + Also stamps that model onto ``_hidden_params`` so downstream consumers (spend logs, + response restamping) can read it instead of guessing the route from the model string. """ - from litellm.llms.azure_ai.common_utils import AzureFoundryModelInfo + from litellm.llms.azure_ai.common_utils import ( + AZURE_MODEL_ROUTER_SELECTED_MODEL_KEY, + AzureFoundryModelInfo, + ) + from litellm.router_utils.add_retry_fallback_headers import ( + get_hidden_params_dict, + ) # Get base model for the parent call (strips routing prefixes for API compatibility) base_model: Final[str] = AzureFoundryModelInfo.get_base_model(model) # Call parent transform_response first - this will extract the actual model # from the raw response (e.g., "gpt-5-nano-2025-08-07") - model_response = super().transform_response( + transformed_response: Final = super().transform_response( model=base_model, raw_response=raw_response, model_response=model_response, @@ -86,7 +95,15 @@ class AzureModelRouterConfig(AzureAIStudioConfig): api_key=api_key, json_mode=json_mode, ) - return model_response + selected_model: Final = transformed_response.model + if selected_model: + # Rebuilt rather than mutated in place: ModelResponseBase declares _hidden_params as a + # class-level dict, so an in-place write can bleed into unrelated responses. + transformed_response._hidden_params = { # pyright: ignore[reportPrivateUsage] # ModelResponse exposes no public hidden-params setter + **get_hidden_params_dict(transformed_response), + AZURE_MODEL_ROUTER_SELECTED_MODEL_KEY: selected_model, + } + return transformed_response def calculate_additional_costs(self, model: str, prompt_tokens: int, completion_tokens: int) -> dict | None: """ diff --git a/litellm/llms/azure_ai/common_utils.py b/litellm/llms/azure_ai/common_utils.py index d25a8fd6561..9d37d8d1185 100644 --- a/litellm/llms/azure_ai/common_utils.py +++ b/litellm/llms/azure_ai/common_utils.py @@ -1,3 +1,4 @@ +from collections.abc import Mapping from typing import Final, Literal import litellm @@ -5,6 +6,8 @@ from litellm.llms.base_llm.base_utils import BaseLLMModelInfo, BaseTokenCounter from litellm.secret_managers.main import get_secret_str from litellm.types.llms.openai import AllMessageValues +AZURE_MODEL_ROUTER_SELECTED_MODEL_KEY: Final = "azure_model_router_selected_model" + class AzureFoundryModelInfo(BaseLLMModelInfo): """Model info for Azure AI / Azure Foundry models.""" @@ -37,6 +40,39 @@ class AzureFoundryModelInfo(BaseLLMModelInfo): return "model_router" return "default" + @staticmethod + def get_model_router_selected_model(hidden_params: Mapping[str, object] | None) -> str | None: + """The model Azure Model Router actually served, stamped by ``AzureModelRouterConfig``. + + Reading this beats re-deriving the route from a model string: the stamp is set on the + code path that was actually taken, so it holds no matter what the caller named the model. + """ + if not hidden_params: + return None + selected: Final = hidden_params.get(AZURE_MODEL_ROUTER_SELECTED_MODEL_KEY) + if isinstance(selected, str) and selected: + return selected + return None + + @staticmethod + def is_model_router_call( + model: str | None = None, + hidden_params: Mapping[str, object] | None = None, + ) -> bool: + """Whether a request went down the Azure Model Router route. + + Prefers the response stamp, then the deployment's litellm model path, and only then the + caller-supplied name. The last two go through ``get_azure_ai_route`` so the model-router + name heuristic lives in exactly one place. + """ + if AzureFoundryModelInfo.get_model_router_selected_model(hidden_params) is not None: + return True + deployment_model: Final = (hidden_params or {}).get("litellm_model_name") or (hidden_params or {}).get("model") + return any( + isinstance(candidate, str) and AzureFoundryModelInfo.get_azure_ai_route(candidate) == "model_router" + for candidate in (deployment_model, model) + ) + @staticmethod def get_api_base(api_base: str | None = None) -> str | None: return api_base or litellm.api_base or get_secret_str("AZURE_AI_API_BASE") diff --git a/litellm/proxy/common_request_processing.py b/litellm/proxy/common_request_processing.py index e5714ef66fb..37a947f7f19 100644 --- a/litellm/proxy/common_request_processing.py +++ b/litellm/proxy/common_request_processing.py @@ -1136,24 +1136,25 @@ async def open_sse_before_first_byte( ) -def _is_azure_model_router_request(model: str) -> bool: +def _is_azure_model_router_request(model: str, hidden_params: Mapping[str, object] | None = None) -> bool: """ - Check if the requested model is an Azure Model Router. + Check if a request went down the Azure Model Router route. - Azure Model Router models follow the pattern: - - azure_ai/model_router/ - - azure_ai/model-router - - model_router/ - - model-router + ``model`` here is what the *client* sent, a model group alias with no ``model_router/`` + prefix, so matching on it alone only works when the operator happened to put "model-router" + in the alias. Where the response is in hand its stamp answers this outright, so callers + should pass ``hidden_params``. Args: model: The requested model name + hidden_params: ``_hidden_params`` from the response, when the caller has it Returns: bool: True if this is an Azure Model Router request """ - model_lower: Final = model.lower() - return "model-router" in model_lower or "model_router" in model_lower + from litellm.llms.azure_ai.common_utils import AzureFoundryModelInfo + + return AzureFoundryModelInfo.is_model_router_call(model=model, hidden_params=hidden_params) def _override_openai_response_model( @@ -1221,7 +1222,7 @@ def _override_openai_response_model( return # Check if this is an Azure Model Router request - if so, preserve the actual model used - if _is_azure_model_router_request(requested_model): + if _is_azure_model_router_request(requested_model, hidden_params): verbose_proxy_logger.debug( "%s: Azure Model Router detected - preserving actual model used from response instead of overriding to router model.", log_context, 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 82de634b488..a0f7de6b320 100644 --- a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py +++ b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py @@ -6,9 +6,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -sys.path.insert( - 0, os.path.abspath("../../..") -) # Adds the parent directory to the system path +sys.path.insert(0, os.path.abspath("../../..")) # Adds the parent directory to the system path import time @@ -277,9 +275,7 @@ def test_response_cost_calculator_uses_router_model_id_from_litellm_metadata(): assert cost is not None, "Cost should not be None" expected_cost = (10 * custom_input_cost) + (5 * custom_output_cost) - assert cost == pytest.approx( - expected_cost - ), f"Expected {expected_cost}, got {cost}" + assert cost == pytest.approx(expected_cost), f"Expected {expected_cost}, got {cost}" finally: litellm.model_cost.pop(custom_model_id, None) @@ -876,13 +872,8 @@ async def test_datadog_logger_not_shadowed_by_llm_obs(monkeypatch): # Regression check: we expect a distinct DataDogLogger, not the LLM Obs logger assert type(datadog_logger) is DataDogLogger - assert any( - isinstance(cb, DataDogLLMObsLogger) - for cb in logging_module._in_memory_loggers - ) - assert any( - type(cb) is DataDogLogger for cb in logging_module._in_memory_loggers - ) + assert any(isinstance(cb, DataDogLLMObsLogger) for cb in logging_module._in_memory_loggers) + assert any(type(cb) is DataDogLogger for cb in logging_module._in_memory_loggers) finally: logging_module._in_memory_loggers.clear() @@ -893,9 +884,7 @@ async def test_logfire_logger_accepts_env_vars_for_base_url(monkeypatch): # Required env vars for Logfire integration monkeypatch.setenv("LOGFIRE_TOKEN", "test-token") - monkeypatch.setenv( - "LOGFIRE_BASE_URL", "https://logfire-api-custom.pydantic.dev" - ) # no trailing slash on purpose + monkeypatch.setenv("LOGFIRE_BASE_URL", "https://logfire-api-custom.pydantic.dev") # no trailing slash on purpose # Import after env vars are set (important if module-level caching exists) from litellm.integrations.opentelemetry import OpenTelemetry # logger class @@ -914,9 +903,7 @@ async def test_logfire_logger_accepts_env_vars_for_base_url(monkeypatch): # Sanity: we got the right logger type and it is cached assert type(logger) is OpenTelemetry - assert any( - type(cb) is OpenTelemetry for cb in logging_module._in_memory_loggers - ) + assert any(type(cb) is OpenTelemetry for cb in logging_module._in_memory_loggers) # Core regression check: base URL env var should influence the exporter endpoint. # @@ -927,9 +914,7 @@ async def test_logfire_logger_accepts_env_vars_for_base_url(monkeypatch): or getattr(logger, "config", None) or getattr(logger, "_otel_config", None) ) - assert ( - cfg is not None - ), "Expected OpenTelemetry logger to keep an otel config on the instance" + assert cfg is not None, "Expected OpenTelemetry logger to keep an otel config on the instance" endpoint = getattr(cfg, "endpoint", None) or getattr(cfg, "otlp_endpoint", None) assert endpoint is not None, "Expected otel config to expose the OTLP endpoint" @@ -1087,9 +1072,7 @@ async def test_logging_non_streaming_request(): # Use the filtered call for assertions call_args = calls_with_expected_input[0] - standard_logging_object = call_args.kwargs["kwargs"][ - "standard_logging_object" - ] + standard_logging_object = call_args.kwargs["kwargs"]["standard_logging_object"] assert standard_logging_object["stream"] is not True finally: # Restore original callbacks to ensure test isolation @@ -1107,18 +1090,14 @@ async def test_logging_non_streaming_request(): "agenerate_content_stream", ], ) -def test_success_handler_skips_sync_callbacks_for_async_requests( - logging_obj, async_flag -): +def test_success_handler_skips_sync_callbacks_for_async_requests(logging_obj, async_flag): """Ensure sync success callbacks are skipped when async call type flags are set.""" from litellm.integrations.custom_logger import CustomLogger class DummyLogger(CustomLogger): pass - logging_obj.stream = ( - False # simulate non-streaming request where sync callbacks would normally run - ) + logging_obj.stream = False # simulate non-streaming request where sync callbacks would normally run logging_obj.model_call_details["litellm_params"] = {async_flag: True} logging_obj.litellm_params = logging_obj.model_call_details["litellm_params"] @@ -1194,21 +1173,11 @@ def test_success_handler_runs_sync_callbacks_for_sync_requests(logging_obj, call def test_is_sync_litellm_request(): assert LitellmLogging._is_sync_litellm_request({}) is True assert LitellmLogging._is_sync_litellm_request({"acompletion": True}) is False - assert ( - LitellmLogging._is_sync_litellm_request({"allm_passthrough_route": True}) - is False - ) - assert ( - LitellmLogging._is_sync_litellm_request({"aanthropic_messages": True}) is False - ) + assert LitellmLogging._is_sync_litellm_request({"allm_passthrough_route": True}) is False + assert LitellmLogging._is_sync_litellm_request({"aanthropic_messages": True}) is False assert LitellmLogging._is_sync_litellm_request({"agenerate_content": True}) is False - assert ( - LitellmLogging._is_sync_litellm_request({"agenerate_content_stream": True}) - is False - ) - assert ( - LitellmLogging._is_sync_litellm_request({"aanthropic_messages": False}) is True - ) + assert LitellmLogging._is_sync_litellm_request({"agenerate_content_stream": True}) is False + assert LitellmLogging._is_sync_litellm_request({"aanthropic_messages": False}) is True def test_get_litellm_params_propagates_allm_passthrough_route(): @@ -1255,9 +1224,7 @@ async def test_dispatch_success_handlers_invokes_callbacks_once_for_final_stream logging_obj.model_call_details["litellm_params"] = {"acompletion": True} with ( - patch.object( - mock_callback, "async_log_success_event", new_callable=AsyncMock - ) as mock_async_log, + patch.object(mock_callback, "async_log_success_event", new_callable=AsyncMock) as mock_async_log, patch.object(mock_callback, "log_success_event") as mock_sync_log, patch.object( logging_obj, @@ -1318,9 +1285,7 @@ async def test_dispatch_success_handlers_sync_path_invokes_callback_once_for_fin with ( patch.object(mock_callback, "log_success_event") as mock_sync_log, - patch.object( - mock_callback, "async_log_success_event", new_callable=AsyncMock - ) as mock_async_log, + patch.object(mock_callback, "async_log_success_event", new_callable=AsyncMock) as mock_async_log, patch.object( logging_obj, "_success_handler_helper_fn", @@ -1362,20 +1327,14 @@ async def test_dispatch_prefer_async_handlers_runs_legacy_callbacks( logging_obj.model_call_details["litellm_params"] = {} with ( - patch.object( - logging_obj, "async_success_handler", new_callable=AsyncMock - ) as mock_async, - patch.object( - logging_obj, "success_handler", new_callable=MagicMock - ) as mock_sync, + patch.object(logging_obj, "async_success_handler", new_callable=AsyncMock) as mock_async, + patch.object(logging_obj, "success_handler", new_callable=MagicMock) as mock_sync, patch.object( logging_obj, "_should_run_sync_callbacks_for_async_calls", return_value=True, ), - patch( - "litellm.litellm_core_utils.litellm_logging.executor.submit" - ) as mock_submit, + patch("litellm.litellm_core_utils.litellm_logging.executor.submit") as mock_submit, ): await logging_obj.dispatch_success_handlers( result=result, @@ -1409,9 +1368,7 @@ async def test_dispatch_success_handlers_invokes_async_callback_for_pass_through try: with ( - patch.object( - mock_callback, "async_log_success_event", new_callable=AsyncMock - ) as mock_async_log, + patch.object(mock_callback, "async_log_success_event", new_callable=AsyncMock) as mock_async_log, patch.object(mock_callback, "log_success_event") as mock_sync_log, ): await logging_obj.dispatch_success_handlers(result={"id": "pt-1"}) @@ -1438,20 +1395,14 @@ async def test_dispatch_failure_handlers_prefer_async_does_not_submit_sync_handl logging_obj.model_call_details["litellm_params"] = {} with ( - patch.object( - logging_obj, "async_failure_handler", new_callable=AsyncMock - ) as mock_async, - patch.object( - logging_obj, "failure_handler", new_callable=MagicMock - ) as mock_sync, + patch.object(logging_obj, "async_failure_handler", new_callable=AsyncMock) as mock_async, + patch.object(logging_obj, "failure_handler", new_callable=MagicMock) as mock_sync, patch.object( logging_obj, "_should_run_sync_failure_callbacks_for_async_calls", return_value=False, ), - patch( - "litellm.litellm_core_utils.litellm_logging.executor.submit" - ) as mock_submit, + patch("litellm.litellm_core_utils.litellm_logging.executor.submit") as mock_submit, ): await logging_obj.dispatch_failure_handlers( exception, @@ -1534,12 +1485,8 @@ async def test_dispatch_failure_handlers_submits_sync_handler_for_failure_only_c patch.object(litellm, "success_callback", []), patch.object(litellm, "failure_callback", [_sync_failure_callback]), patch.object(logging_obj, "async_failure_handler", new_callable=AsyncMock), - patch.object( - logging_obj, "failure_handler", new_callable=MagicMock - ) as mock_sync, - patch( - "litellm.litellm_core_utils.litellm_logging.executor.submit" - ) as mock_submit, + patch.object(logging_obj, "failure_handler", new_callable=MagicMock) as mock_sync, + patch("litellm.litellm_core_utils.litellm_logging.executor.submit") as mock_submit, ): await logging_obj.dispatch_failure_handlers( exception, @@ -1566,15 +1513,9 @@ async def test_dispatch_failure_handlers_sync_sdk_shortcut_runs_sync_handler_inl logging_obj.model_call_details["litellm_params"] = {} with ( - patch.object( - logging_obj, "async_failure_handler", new_callable=AsyncMock - ) as mock_async, - patch.object( - logging_obj, "failure_handler", new_callable=MagicMock - ) as mock_sync, - patch( - "litellm.litellm_core_utils.litellm_logging.executor.submit" - ) as mock_submit, + patch.object(logging_obj, "async_failure_handler", new_callable=AsyncMock) as mock_async, + patch.object(logging_obj, "failure_handler", new_callable=MagicMock) as mock_sync, + patch("litellm.litellm_core_utils.litellm_logging.executor.submit") as mock_submit, ): await logging_obj.dispatch_failure_handlers( exception, @@ -1621,14 +1562,10 @@ def test_success_handler_skips_guardrail_logging_hook_when_disabled(logging_obj) event_hook=GuardrailEventHooks.logging_only, ) guardrail.should_run_guardrail = MagicMock(return_value=False) - guardrail.logging_hook = MagicMock( - return_value=(logging_obj.model_call_details, model_response) - ) + guardrail.logging_hook = MagicMock(return_value=(logging_obj.model_call_details, model_response)) dummy_logger = DummyLogger() - dummy_logger.logging_hook = MagicMock( - return_value=(logging_obj.model_call_details, model_response) - ) + dummy_logger.logging_hook = MagicMock(return_value=(logging_obj.model_call_details, model_response)) with patch.object( logging_obj, @@ -1762,11 +1699,7 @@ def test_get_request_tags_from_metadata_and_litellm_metadata(): # Test case 2: Tags in litellm_metadata only tags = StandardLoggingPayloadSetup._get_request_tags( - litellm_params={ - "litellm_metadata": { - "tags": ["litellm-metadata-tag-1", "litellm-metadata-tag-2"] - } - }, + litellm_params={"litellm_metadata": {"tags": ["litellm-metadata-tag-1", "litellm-metadata-tag-2"]}}, proxy_server_request={}, ) assert "litellm-metadata-tag-1" in tags @@ -1871,15 +1804,9 @@ def test_get_request_tags_does_not_mutate_original_tags(): user_agent_count_2 = len([t for t in tags2 if t.startswith("User-Agent:")]) user_agent_count_3 = len([t for t in tags3 if t.startswith("User-Agent:")]) - assert ( - user_agent_count_1 == 2 - ), f"Expected 2 User-Agent tags, got {user_agent_count_1}" - assert ( - user_agent_count_2 == 2 - ), f"Expected 2 User-Agent tags, got {user_agent_count_2}" - assert ( - user_agent_count_3 == 2 - ), f"Expected 2 User-Agent tags, got {user_agent_count_3}" + assert user_agent_count_1 == 2, f"Expected 2 User-Agent tags, got {user_agent_count_1}" + assert user_agent_count_2 == 2, f"Expected 2 User-Agent tags, got {user_agent_count_2}" + assert user_agent_count_3 == 2, f"Expected 2 User-Agent tags, got {user_agent_count_3}" # Verify all returned lists are independent (different objects) assert tags1 is not tags2 @@ -1912,9 +1839,7 @@ def test_get_extra_header_tags(): # Test case 3: Extra headers configured but request has no headers dict litellm.extra_spend_tag_headers = ["x-custom", "x-tenant"] - result = StandardLoggingPayloadSetup._get_extra_header_tags( - proxy_server_request={"headers": "not-a-dict"} - ) + result = StandardLoggingPayloadSetup._get_extra_header_tags(proxy_server_request={"headers": "not-a-dict"}) assert result is None # Test case 4: Extra headers configured but none match request headers @@ -2215,9 +2140,7 @@ def test_get_masked_values(): "presidio_anonymizer_api_base": None, "vertex_credentials": "{sensitive_api_key}", } - masked_values = _get_masked_values( - sensitive_object, unmasked_length=4, number_of_asterisks=4 - ) + masked_values = _get_masked_values(sensitive_object, unmasked_length=4, number_of_asterisks=4) assert masked_values["presidio_anonymizer_api_base"] is None assert masked_values["vertex_credentials"] == "{s****y}" @@ -2242,9 +2165,7 @@ async def test_e2e_generate_cold_storage_object_key_successful(): patch("litellm.integrations.s3.get_s3_object_key") as mock_get_s3_key, ): # Mock the S3 object key generation to return a predictable result - mock_get_s3_key.return_value = ( - "2025-01-15/time-10-30-45-123456_chatcmpl-test-12345.json" - ) + mock_get_s3_key.return_value = "2025-01-15/time-10-30-45-123456_chatcmpl-test-12345.json" # Call the function result = StandardLoggingPayloadSetup._generate_cold_storage_object_key( @@ -2285,16 +2206,12 @@ async def test_e2e_generate_cold_storage_object_key_with_custom_logger_s3_path() with ( patch("litellm.cold_storage_custom_logger", "s3_v2"), - patch( - "litellm.logging_callback_manager.get_active_custom_logger_for_callback_name" - ) as mock_get_logger, + patch("litellm.logging_callback_manager.get_active_custom_logger_for_callback_name") as mock_get_logger, patch("litellm.integrations.s3.get_s3_object_key") as mock_get_s3_key, ): # Setup mocks mock_get_logger.return_value = mock_custom_logger - mock_get_s3_key.return_value = ( - "storage/2025-01-15/time-10-30-45-123456_chatcmpl-test-12345.json" - ) + mock_get_s3_key.return_value = "storage/2025-01-15/time-10-30-45-123456_chatcmpl-test-12345.json" # Call the function result = StandardLoggingPayloadSetup._generate_cold_storage_object_key( @@ -2313,9 +2230,7 @@ async def test_e2e_generate_cold_storage_object_key_with_custom_logger_s3_path() ) # Verify the result - assert ( - result == "storage/2025-01-15/time-10-30-45-123456_chatcmpl-test-12345.json" - ) + assert result == "storage/2025-01-15/time-10-30-45-123456_chatcmpl-test-12345.json" @pytest.mark.asyncio @@ -2338,16 +2253,12 @@ async def test_e2e_generate_cold_storage_object_key_with_logger_no_s3_path(): with ( patch("litellm.cold_storage_custom_logger", "s3_v2"), - patch( - "litellm.logging_callback_manager.get_active_custom_logger_for_callback_name" - ) as mock_get_logger, + patch("litellm.logging_callback_manager.get_active_custom_logger_for_callback_name") as mock_get_logger, patch("litellm.integrations.s3.get_s3_object_key") as mock_get_s3_key, ): # Setup mocks mock_get_logger.return_value = mock_custom_logger - mock_get_s3_key.return_value = ( - "2025-01-15/time-10-30-45-123456_chatcmpl-test-12345.json" - ) + mock_get_s3_key.return_value = "2025-01-15/time-10-30-45-123456_chatcmpl-test-12345.json" # Call the function result = StandardLoggingPayloadSetup._generate_cold_storage_object_key( @@ -2463,9 +2374,7 @@ def test_get_usage_as_dict(): assert result == {"prompt_tokens": 20, "completion_tokens": 30} # Test case 5: response_obj with no usage key returns empty - result = StandardLoggingPayloadSetup.get_usage_as_dict( - response_obj={"id": "resp-1", "choices": []} - ) + result = StandardLoggingPayloadSetup.get_usage_as_dict(response_obj={"id": "resp-1", "choices": []}) assert result == {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0} @@ -2478,26 +2387,20 @@ def test_append_system_prompt_messages(): # Test case 1: system in kwargs with existing messages kwargs = {"system": "You are a helpful assistant"} messages = [{"role": "user", "content": "Hello"}] - result = StandardLoggingPayloadSetup.append_system_prompt_messages( - kwargs=kwargs, messages=messages - ) + result = StandardLoggingPayloadSetup.append_system_prompt_messages(kwargs=kwargs, messages=messages) assert len(result) == 2 assert result[0] == {"role": "system", "content": "You are a helpful assistant"} assert result[1] == {"role": "user", "content": "Hello"} # Test case 2: system in kwargs with None messages kwargs = {"system": "You are a helpful assistant"} - result = StandardLoggingPayloadSetup.append_system_prompt_messages( - kwargs=kwargs, messages=None - ) + result = StandardLoggingPayloadSetup.append_system_prompt_messages(kwargs=kwargs, messages=None) assert len(result) == 1 assert result[0] == {"role": "system", "content": "You are a helpful assistant"} # Test case 3: system in kwargs with empty messages list kwargs = {"system": "You are a helpful assistant"} - result = StandardLoggingPayloadSetup.append_system_prompt_messages( - kwargs=kwargs, messages=[] - ) + result = StandardLoggingPayloadSetup.append_system_prompt_messages(kwargs=kwargs, messages=[]) assert len(result) == 1 assert result[0] == {"role": "system", "content": "You are a helpful assistant"} @@ -2507,24 +2410,18 @@ def test_append_system_prompt_messages(): {"role": "system", "content": "You are a helpful assistant"}, {"role": "user", "content": "Hello"}, ] - result = StandardLoggingPayloadSetup.append_system_prompt_messages( - kwargs=kwargs, messages=messages - ) + result = StandardLoggingPayloadSetup.append_system_prompt_messages(kwargs=kwargs, messages=messages) assert len(result) == 2 assert result[0] == {"role": "system", "content": "You are a helpful assistant"} # Test case 5: no system in kwargs returns messages unchanged kwargs = {} messages = [{"role": "user", "content": "Hello"}] - result = StandardLoggingPayloadSetup.append_system_prompt_messages( - kwargs=kwargs, messages=messages - ) + result = StandardLoggingPayloadSetup.append_system_prompt_messages(kwargs=kwargs, messages=messages) assert result == messages # Test case 6: None kwargs returns messages unchanged - result = StandardLoggingPayloadSetup.append_system_prompt_messages( - kwargs=None, messages=messages - ) + result = StandardLoggingPayloadSetup.append_system_prompt_messages(kwargs=None, messages=messages) assert result == messages @@ -2585,12 +2482,11 @@ async def test_async_success_handler_sets_standard_logging_object_for_pass_throu # Verify that standard_logging_object was set assert "standard_logging_object" in logging_obj.model_call_details, ( - "standard_logging_object should be set for pass-through endpoints " - "even when complete_streaming_response is None" + "standard_logging_object should be set for pass-through endpoints even when complete_streaming_response is None" + ) + assert logging_obj.model_call_details["standard_logging_object"] is not None, ( + "standard_logging_object should not be None for pass-through endpoints" ) - assert ( - logging_obj.model_call_details["standard_logging_object"] is not None - ), "standard_logging_object should not be None for pass-through endpoints" # Verify that async_complete_streaming_response was set to prevent re-processing # This is consistent with the existing code pattern for regular streaming @@ -2598,15 +2494,13 @@ async def test_async_success_handler_sets_standard_logging_object_for_pass_throu "async_complete_streaming_response should be set to prevent re-processing, " "consistent with the existing code pattern" ) - assert ( - logging_obj.model_call_details["async_complete_streaming_response"] is result - ), "async_complete_streaming_response should be set to the result" + assert logging_obj.model_call_details["async_complete_streaming_response"] is result, ( + "async_complete_streaming_response should be set to the result" + ) # Verify that response_cost is set to None (cost calculation not possible for pass-through) # This is consistent with the error handling in the non-pass-through code path - assert ( - "response_cost" in logging_obj.model_call_details - ), "response_cost should be set for pass-through endpoints" + assert "response_cost" in logging_obj.model_call_details, "response_cost should be set for pass-through endpoints" assert logging_obj.model_call_details["response_cost"] is None, ( "response_cost should be None for pass-through endpoints since " "StandardPassThroughResponseObject doesn't have standard usage info" @@ -2665,14 +2559,10 @@ async def test_async_success_handler_prevents_reprocessing_for_pass_through_endp # Verify first call set the values assert "standard_logging_object" in logging_obj.model_call_details assert "async_complete_streaming_response" in logging_obj.model_call_details - first_standard_logging_object = logging_obj.model_call_details[ - "standard_logging_object" - ] + first_standard_logging_object = logging_obj.model_call_details["standard_logging_object"] # Second call - should return early due to async_complete_streaming_response guard - with patch.object( - logging_obj, "get_combined_callback_list", return_value=[] - ) as mock_callbacks: + with patch.object(logging_obj, "get_combined_callback_list", return_value=[]) as mock_callbacks: await logging_obj.async_success_handler( result=result, start_time=start_time, @@ -2683,10 +2573,9 @@ async def test_async_success_handler_prevents_reprocessing_for_pass_through_endp mock_callbacks.assert_not_called() # Verify standard_logging_object wasn't modified by second call - assert ( - logging_obj.model_call_details["standard_logging_object"] - is first_standard_logging_object - ), "standard_logging_object should not be modified on re-processing" + assert logging_obj.model_call_details["standard_logging_object"] is first_standard_logging_object, ( + "standard_logging_object should not be modified on re-processing" + ) @pytest.mark.asyncio @@ -2725,9 +2614,7 @@ async def test_async_success_handler_sets_standard_logging_object_for_streaming_ } # Create a pass-through response object (simulating unparseable streaming response) - result = StandardPassThroughResponseObject( - response='data: {"chunk": 1}\ndata: {"chunk": 2}\ndata: [DONE]' - ) + result = StandardPassThroughResponseObject(response='data: {"chunk": 1}\ndata: {"chunk": 2}\ndata: [DONE]') start_time = datetime.now() end_time = datetime.now() @@ -2747,9 +2634,9 @@ async def test_async_success_handler_sets_standard_logging_object_for_streaming_ "standard_logging_object should be set for streaming pass-through endpoints " "even when the response cannot be parsed into a ModelResponse" ) - assert ( - logging_obj.model_call_details["standard_logging_object"] is not None - ), "standard_logging_object should not be None for streaming pass-through endpoints" + assert logging_obj.model_call_details["standard_logging_object"] is not None, ( + "standard_logging_object should not be None for streaming pass-through endpoints" + ) def test_get_error_information_error_code_priority(): @@ -2791,30 +2678,22 @@ def test_get_error_information_error_code_priority(): self.message = message super().__init__(message) - both_exception = BothAttributesException( - code="400", status_code=500, message="Bad Request" - ) + both_exception = BothAttributesException(code="400", status_code=500, message="Bad Request") result = StandardLoggingPayloadSetup.get_error_information(both_exception) assert result["error_code"] == "400" # Should prefer 'code' over 'status_code' # Test case 4: Exception with 'code' as empty string - should fall back to 'status_code' - empty_code_exception = BothAttributesException( - code="", status_code=404, message="Not Found" - ) + empty_code_exception = BothAttributesException(code="", status_code=404, message="Not Found") result = StandardLoggingPayloadSetup.get_error_information(empty_code_exception) assert result["error_code"] == "404" # Should fall back to status_code # Test case 5: Exception with 'code' as "None" string - should fall back to 'status_code' - none_string_exception = BothAttributesException( - code="None", status_code=503, message="Service Unavailable" - ) + none_string_exception = BothAttributesException(code="None", status_code=503, message="Service Unavailable") result = StandardLoggingPayloadSetup.get_error_information(none_string_exception) assert result["error_code"] == "503" # Should fall back to status_code # Test case 6: Exception with 'code' as None - should fall back to 'status_code' - none_code_exception = BothAttributesException( - code=None, status_code=401, message="Unauthorized" - ) + none_code_exception = BothAttributesException(code=None, status_code=401, message="Unauthorized") result = StandardLoggingPayloadSetup.get_error_information(none_code_exception) assert result["error_code"] == "401" # Should fall back to status_code @@ -2863,9 +2742,7 @@ def test_get_error_information_prefers_message_attribute_over_str(): ) result = StandardLoggingPayloadSetup.get_error_information(exc) - assert ( - result["error_message"] == msg - ), f"expected message from .message attribute, got {result['error_message']!r}" + assert result["error_message"] == msg, f"expected message from .message attribute, got {result['error_message']!r}" assert result["error_code"] == "401" assert result["error_class"] == "ProxyExceptionLike" @@ -2940,8 +2817,7 @@ def test_get_error_information_preserves_explicit_empty_message(): exc = ProxyExceptionLike(message="", code=500) result = StandardLoggingPayloadSetup.get_error_information(exc) assert result["error_message"] == "", ( - "explicit empty .message must survive verbatim; got " - f"{result['error_message']!r}" + f"explicit empty .message must survive verbatim; got {result['error_message']!r}" ) @@ -3204,9 +3080,7 @@ def test_process_hidden_params_recalculates_cost_after_failure_handler_zero(): choices=[{"message": {"role": "assistant", "content": "ok"}}], usage=Usage(prompt_tokens=9698, completion_tokens=30, total_tokens=9728), ) - logging_obj._process_hidden_params_and_response_cost( - result, datetime.now(), datetime.now() - ) + logging_obj._process_hidden_params_and_response_cost(result, datetime.now(), datetime.now()) cost = logging_obj.model_call_details.get("response_cost") assert cost is not None and cost > 0 @@ -3230,9 +3104,7 @@ def test_process_hidden_params_preserves_zero_cost_in_hidden_params(): litellm_call_id="test-hidden-zero-cost", function_id="test-hidden-zero-cost", ) - logging_obj.model_call_details["litellm_params"] = { - "model": "gemini-2.5-flash-lite" - } + logging_obj.model_call_details["litellm_params"] = {"model": "gemini-2.5-flash-lite"} logging_obj.optional_params = {} result = ModelResponse( @@ -3242,9 +3114,7 @@ def test_process_hidden_params_preserves_zero_cost_in_hidden_params(): ) result._hidden_params = {"response_cost": 0.0} - logging_obj._process_hidden_params_and_response_cost( - result, datetime.now(), datetime.now() - ) + logging_obj._process_hidden_params_and_response_cost(result, datetime.now(), datetime.now()) assert logging_obj.model_call_details.get("response_cost") == 0.0 slo = logging_obj.model_call_details.get("standard_logging_object") or {} @@ -3293,9 +3163,7 @@ def test_process_hidden_params_uses_hidden_params_cost_after_failure_handler_zer ) result._hidden_params = {"response_cost": passthrough_cost} - logging_obj._process_hidden_params_and_response_cost( - result, datetime.now(), datetime.now() - ) + logging_obj._process_hidden_params_and_response_cost(result, datetime.now(), datetime.now()) assert logging_obj.model_call_details.get("response_cost") == passthrough_cost slo = logging_obj.model_call_details.get("standard_logging_object") or {} @@ -3352,9 +3220,7 @@ def test_function_setup_litellm_metadata_populates_metadata(): assert litellm_metadata.get("user_api_key_hash") == test_api_key_hash # metadata should be a COPY, not an alias — mutating one must not affect the other - assert ( - metadata is not litellm_metadata - ), "litellm_params['metadata'] should be a copy, not the same object" + assert metadata is not litellm_metadata, "litellm_params['metadata'] should be a copy, not the same object" def test_function_setup_litellm_metadata_guardrail_writes_visible_after_setup(): @@ -3399,9 +3265,9 @@ def test_function_setup_litellm_metadata_guardrail_writes_visible_after_setup(): litellm_params = logging_obj.model_call_details.get("litellm_params", {}) litellm_metadata = litellm_params.get("litellm_metadata") assert litellm_metadata is not None - assert litellm_metadata.get("standard_logging_guardrail_information") == [ - guardrail_entry - ], "guardrail writes after function_setup must be visible to the logging object" + assert litellm_metadata.get("standard_logging_guardrail_information") == [guardrail_entry], ( + "guardrail writes after function_setup must be visible to the logging object" + ) assert litellm_metadata.get("applied_guardrails") == ["pam-ethical-request"] merged = StandardLoggingPayloadSetup.merge_litellm_metadata(litellm_params) @@ -3570,9 +3436,7 @@ def test_failure_handler_skips_sync_callbacks_for_pass_through_requests(logging_ @pytest.mark.parametrize("call_type", ["completion", "acompletion"]) -def test_failure_handler_runs_sync_callbacks_for_non_pass_through_requests( - logging_obj, call_type -): +def test_failure_handler_runs_sync_callbacks_for_non_pass_through_requests(logging_obj, call_type): """Ensure sync failure callbacks still fire for normal (non-pass-through) requests.""" from litellm.integrations.custom_logger import CustomLogger @@ -3733,9 +3597,7 @@ def test_standard_logging_hidden_params_backfills_response_cost_without_mutating ) response._hidden_params = {"response_cost": None, "model_id": "mid-test"} - payload = logging_obj._build_standard_logging_payload( - response, datetime.now(), datetime.now() - ) + payload = logging_obj._build_standard_logging_payload(response, datetime.now(), datetime.now()) assert payload is not None assert payload["hidden_params"]["response_cost"] == 0.002 @@ -3789,10 +3651,7 @@ def test_merge_hidden_params_from_response_into_metadata_no_op_when_empty(): _hidden_params = {} logging_obj._merge_hidden_params_from_response_into_metadata(_NoHp()) - assert ( - "hidden_params" - not in logging_obj.model_call_details["litellm_params"]["metadata"] - ) + assert "hidden_params" not in logging_obj.model_call_details["litellm_params"]["metadata"] # ── StandardLoggingPayloadSetup.get_additional_headers ─────────────────────── @@ -3870,6 +3729,82 @@ def test_get_standard_logging_object_payload_includes_litellm_call_id(logging_ob assert payload["litellm_call_id"] == call_id +# ── Azure Model Router selected-model attribution ──────────────────────────── + + +def _model_router_response(selected_model: str, stamp: bool): + """A ModelResponse as AzureModelRouterConfig hands it back, with or without the stamp.""" + from litellm.llms.azure_ai.common_utils import ( + AZURE_MODEL_ROUTER_SELECTED_MODEL_KEY, + ) + from litellm.types.utils import ModelResponse + + response = ModelResponse(model=selected_model) + response._hidden_params = {AZURE_MODEL_ROUTER_SELECTED_MODEL_KEY: selected_model} if stamp else {} + return response + + +def test_standard_logging_payload_uses_stamped_model_router_model(logging_obj): + """ + The selected model must win off the stamp, not off "model-router" appearing in the + requested model. An operator whose model group is named anything else was invisible + to the name check, so their logs and spend rows named the router instead. + """ + import datetime + + from litellm.litellm_core_utils.litellm_logging import ( + get_standard_logging_object_payload, + ) + + now = datetime.datetime.now() + payload = get_standard_logging_object_payload( + kwargs={ + "model": "azure_ai/smart-pick", + "custom_llm_provider": "azure_ai", + "messages": [], + "litellm_params": {"metadata": {}}, + }, + init_response_obj=_model_router_response("azure_ai/grok-4-1-fast-reasoning", stamp=True), + start_time=now, + end_time=now, + logging_obj=logging_obj, + status="success", + ) + + assert payload is not None + assert payload["model"] == "azure_ai/grok-4-1-fast-reasoning" + + +def test_standard_logging_payload_keeps_requested_model_without_router_stamp(logging_obj): + """ + Control for the test above: an ordinary azure_ai deployment is unaffected, so the stamp + is what redirects attribution rather than the response model winning unconditionally. + """ + import datetime + + from litellm.litellm_core_utils.litellm_logging import ( + get_standard_logging_object_payload, + ) + + now = datetime.datetime.now() + payload = get_standard_logging_object_payload( + kwargs={ + "model": "azure_ai/smart-pick", + "custom_llm_provider": "azure_ai", + "messages": [], + "litellm_params": {"metadata": {}}, + }, + init_response_obj=_model_router_response("azure_ai/grok-4-1-fast-reasoning", stamp=False), + start_time=now, + end_time=now, + logging_obj=logging_obj, + status="success", + ) + + assert payload is not None + assert payload["model"] == "azure_ai/smart-pick" + + def _make_dict_logging_obj(): """Build a Logging instance configured for a non-streaming dict result.""" obj = LitellmLogging( @@ -3905,9 +3840,7 @@ def test_success_handler_computes_cost_for_dict_response(): "_build_standard_logging_payload", return_value={"response_cost": expected_cost}, ), - patch( - "litellm.litellm_core_utils.litellm_logging.emit_standard_logging_payload" - ), + patch("litellm.litellm_core_utils.litellm_logging.emit_standard_logging_payload"), patch.object( logging_obj, "_is_recognized_call_type_for_logging", @@ -3944,9 +3877,7 @@ def test_success_handler_preserves_precomputed_cost_for_dict_response(): "_build_standard_logging_payload", return_value={"response_cost": precomputed_cost}, ), - patch( - "litellm.litellm_core_utils.litellm_logging.emit_standard_logging_payload" - ), + patch("litellm.litellm_core_utils.litellm_logging.emit_standard_logging_payload"), patch.object( logging_obj, "_is_recognized_call_type_for_logging", @@ -3985,9 +3916,7 @@ def test_success_handler_unified_helper_runs_for_typed_results(): "_build_standard_logging_payload", return_value={"response_cost": expected_cost}, ), - patch( - "litellm.litellm_core_utils.litellm_logging.emit_standard_logging_payload" - ), + patch("litellm.litellm_core_utils.litellm_logging.emit_standard_logging_payload"), patch.object( logging_obj, "_is_recognized_call_type_for_logging", @@ -4042,9 +3971,7 @@ class TestFirstApiCallStartTimeSetOnce: assert first == obj.model_call_details["api_call_start_time"] # Set on the logging object only — user metadata untouched. assert user_meta == {} - assert ( - "first_api_call_start_time" not in obj.model_call_details["litellm_params"] - ) + assert "first_api_call_start_time" not in obj.model_call_details["litellm_params"] time.sleep(0.002) # ensure a distinct retry timestamp obj.pre_call(input="hi", api_key="sk-test") @@ -4061,18 +3988,16 @@ def test_get_error_information_for_logging_payload_ignores_spoofed_disconnect_wi baseline = StandardLoggingPayloadSetup.get_error_information( original_exception=ValueError("provider failure"), ) - error_information, error_str = ( - StandardLoggingPayloadSetup.get_error_information_for_logging_payload( - metadata={ - "error_information": { - "error_code": "499", - "error_message": "Client disconnected the request", - "error_class": "ClientDisconnected", - } - }, - original_exception=ValueError("provider failure"), - error_str="provider failure", - ) + error_information, error_str = StandardLoggingPayloadSetup.get_error_information_for_logging_payload( + metadata={ + "error_information": { + "error_code": "499", + "error_message": "Client disconnected the request", + "error_class": "ClientDisconnected", + } + }, + original_exception=ValueError("provider failure"), + error_str="provider failure", ) assert error_information == baseline assert error_str == "provider failure" @@ -4086,22 +4011,18 @@ def test_get_error_information_for_logging_payload_client_disconnect(): "error_message": "Client disconnected the request", "error_class": "ClientDisconnected", } - error_information, error_str = ( - StandardLoggingPayloadSetup.get_error_information_for_logging_payload( - metadata={"client_disconnected": True, "error_information": custom_error}, - original_exception=None, - error_str=None, - ) + error_information, error_str = StandardLoggingPayloadSetup.get_error_information_for_logging_payload( + metadata={"client_disconnected": True, "error_information": custom_error}, + original_exception=None, + error_str=None, ) assert error_information == custom_error assert error_str == "Client disconnected the request" - error_information, error_str = ( - StandardLoggingPayloadSetup.get_error_information_for_logging_payload( - metadata={"client_disconnected": True}, - original_exception=None, - error_str="existing error", - ) + error_information, error_str = StandardLoggingPayloadSetup.get_error_information_for_logging_payload( + metadata={"client_disconnected": True}, + original_exception=None, + error_str="existing error", ) assert error_information["error_code"] == "499" assert error_str == "existing error" @@ -4109,12 +4030,10 @@ def test_get_error_information_for_logging_payload_client_disconnect(): baseline = StandardLoggingPayloadSetup.get_error_information( original_exception=None, ) - error_information, error_str = ( - StandardLoggingPayloadSetup.get_error_information_for_logging_payload( - metadata={}, - original_exception=None, - error_str=None, - ) + error_information, error_str = StandardLoggingPayloadSetup.get_error_information_for_logging_payload( + metadata={}, + original_exception=None, + error_str=None, ) assert error_information == baseline assert error_str is None @@ -4149,9 +4068,7 @@ def test_get_error_information_prefers_message_attribute_over_empty_str(): def __str__(self): return "" - info = StandardLoggingPayloadSetup.get_error_information( - original_exception=_SilentExc() - ) + info = StandardLoggingPayloadSetup.get_error_information(original_exception=_SilentExc()) assert info["error_message"] == "real failure detail" assert info["error_code"] == "401" @@ -4182,9 +4099,7 @@ def _responses_api_response_with_text(text="hello world"): type="message", role="assistant", status="completed", - content=[ - ResponseOutputText(annotations=[], text=text, type="output_text") - ], + content=[ResponseOutputText(annotations=[], text=text, type="output_text")], ) ], usage=ResponseAPIUsage(input_tokens=11, output_tokens=7, total_tokens=18), @@ -4199,9 +4114,7 @@ def _responses_api_response_with_text(text="hello world"): ("ResponseFailedEvent", "response.failed"), ], ) -def test_handle_anthropic_messages_response_logging_translates_terminal_responses_api_event( - event_cls, event_type -): +def test_handle_anthropic_messages_response_logging_translates_terminal_responses_api_event(event_cls, event_type): """Regression for #28595 / #28943. When anthropic_messages routes to the OpenAI Responses backend and stream=True, success_handler receives a terminal Responses API event. The handler must translate it to a ModelResponse whose choices carry @@ -4240,10 +4153,7 @@ def test_handle_anthropic_messages_response_logging_passes_model_response_throug """Anthropic-native path already yields a ModelResponse; it must be returned unchanged.""" logging_obj = _anthropic_messages_logging_obj() model_response = ModelResponse() - assert ( - logging_obj._handle_anthropic_messages_response_logging(result=model_response) - is model_response - ) + assert logging_obj._handle_anthropic_messages_response_logging(result=model_response) is model_response def test_handle_anthropic_messages_response_logging_degrades_on_unparseable_responses_payload(): @@ -4539,9 +4449,7 @@ def test_non_image_response_has_no_output_image_count(logging_obj): def test_zero_token_video_usage_preserves_duration_seconds(logging_obj): """Video usage bills by duration; the payload must keep duration_seconds even with zero tokens.""" - payload = _build_payload_for_media_response( - logging_obj, {"id": "video-1", "usage": {"duration_seconds": 4.0}} - ) + payload = _build_payload_for_media_response(logging_obj, {"id": "video-1", "usage": {"duration_seconds": 4.0}}) assert payload is not None assert payload["metadata"]["usage_object"]["duration_seconds"] == 4.0 diff --git a/tests/test_litellm/llms/azure_ai/chat/test_azure_ai_transformation.py b/tests/test_litellm/llms/azure_ai/chat/test_azure_ai_transformation.py index 900372f3e54..1f684ecc3b5 100644 --- a/tests/test_litellm/llms/azure_ai/chat/test_azure_ai_transformation.py +++ b/tests/test_litellm/llms/azure_ai/chat/test_azure_ai_transformation.py @@ -5,9 +5,7 @@ from unittest.mock import MagicMock, patch import pytest -sys.path.insert( - 0, os.path.abspath("../../../../..") -) # Adds the parent directory to the system path +sys.path.insert(0, os.path.abspath("../../../../..")) # Adds the parent directory to the system path from litellm.llms.azure_ai.azure_model_router.transformation import ( AzureModelRouterConfig, ) @@ -120,9 +118,7 @@ def test_azure_ai_grok_stop_parameter_handling(): # Test supported parameters for Grok models for model in ("grok-4-fast", "grok-4.3"): grok_params = config.get_supported_openai_params(model) - assert ( - "stop" not in grok_params - ), "Grok models should not support stop parameter" + assert "stop" not in grok_params, "Grok models should not support stop parameter" # Test supported parameters for non-Grok models gpt_params = config.get_supported_openai_params("gpt-4") @@ -201,11 +197,84 @@ def test_azure_model_router_response_shows_actual_model(): # Verify that the response contains the actual model used, not the router model assert result.model == "azure_ai/gpt-5-nano-2025-08-07", ( - f"Expected model to be 'azure_ai/gpt-5-nano-2025-08-07' (actual model used), " - f"but got '{result.model}'" + f"Expected model to be 'azure_ai/gpt-5-nano-2025-08-07' (actual model used), but got '{result.model}'" ) +def test_azure_model_router_stamps_selected_model_on_hidden_params(): + """ + The selected model must be stamped on _hidden_params, not left for downstream code to + re-derive by looking for "model-router" in the model string. Deployments whose alias + does not contain that text are invisible to the string check. + """ + from httpx import Response + + from litellm.llms.azure_ai.common_utils import ( + AZURE_MODEL_ROUTER_SELECTED_MODEL_KEY, + AzureFoundryModelInfo, + ) + from litellm.llms.base_llm.chat.transformation import LiteLLMLoggingObj + from litellm.types.utils import ModelResponse + + raw_response_json = { + "id": "chatcmpl-test456", + "object": "chat.completion", + "created": 1234567890, + "model": "grok-4-1-fast-reasoning", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "pong"}, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}, + } + + mock_response = MagicMock(spec=Response) + mock_response.json.return_value = raw_response_json + mock_response.text = json.dumps(raw_response_json) + mock_response.headers = {} + + logging_obj = MagicMock(spec=LiteLLMLoggingObj) + logging_obj.post_call = MagicMock() + logging_obj.model_call_details = {} + + result = AzureModelRouterConfig().transform_response( + model="smart-pick", + raw_response=mock_response, + model_response=ModelResponse(), + logging_obj=logging_obj, + request_data={}, + messages=[{"role": "user", "content": "Reply with just pong"}], + optional_params={}, + litellm_params={"model": "azure_ai/model_router/smart-pick"}, + encoding=None, + api_key="test-key", + json_mode=False, + ) + + assert result._hidden_params[AZURE_MODEL_ROUTER_SELECTED_MODEL_KEY] == result.model + assert result._hidden_params[AZURE_MODEL_ROUTER_SELECTED_MODEL_KEY] == "azure_ai/grok-4-1-fast-reasoning" + assert AzureFoundryModelInfo.get_model_router_selected_model(result._hidden_params) == ( + "azure_ai/grok-4-1-fast-reasoning" + ) + assert AzureFoundryModelInfo.is_model_router_call(model="smart-pick", hidden_params=result._hidden_params) is True + + +def test_azure_model_router_stamp_does_not_leak_across_responses(): + """ + ModelResponse declares _hidden_params as a class-level dict, so the stamp has to be written + as a fresh dict. Mutating in place would bleed the selected model into unrelated responses. + """ + from litellm.llms.azure_ai.common_utils import AZURE_MODEL_ROUTER_SELECTED_MODEL_KEY + from litellm.types.utils import ModelResponse + + untouched = ModelResponse() + + assert AZURE_MODEL_ROUTER_SELECTED_MODEL_KEY not in (untouched._hidden_params or {}) + + def test_drop_tool_level_extra_fields_strips_copilot_mcp_server_name(): """ Regression test: Azure AI returns 400 when tools contain copilot_mcp_server_name. @@ -226,14 +295,10 @@ def test_drop_tool_level_extra_fields_strips_copilot_mcp_server_name(): mock_response.text = error_text mock_response.json.return_value = json.loads(error_text) mock_response.status_code = 400 - e = httpx.HTTPStatusError( - message="400", request=MagicMock(), response=mock_response - ) + e = httpx.HTTPStatusError(message="400", request=MagicMock(), response=mock_response) assert config._error_has_tool_level_extra_fields(error_text) is True - assert ( - config.should_retry_llm_api_inside_llm_translation_on_http_error(e, {}) is True - ) + assert config.should_retry_llm_api_inside_llm_translation_on_http_error(e, {}) is True request_data = { "model": "FW-Kimi-K2.6", @@ -354,9 +419,7 @@ def test_azure_ai_stripping_does_not_mutate_caller_messages(): { "role": "assistant", "content": "I can help.", - "thinking_blocks": [ - {"type": "thinking", "thinking": "Reading the file.", "signature": "sig"} - ], + "thinking_blocks": [{"type": "thinking", "thinking": "Reading the file.", "signature": "sig"}], "provider_specific_fields": {"thought_signature": "sig-top"}, "tool_calls": [ { diff --git a/tests/test_litellm/proxy/test_common_request_processing.py b/tests/test_litellm/proxy/test_common_request_processing.py index 716fba370df..562046b2f81 100644 --- a/tests/test_litellm/proxy/test_common_request_processing.py +++ b/tests/test_litellm/proxy/test_common_request_processing.py @@ -126,16 +126,12 @@ class TestProxyBaseLLMRequestProcessing: assert json.loads(result.body) == guardrailed_body @pytest.mark.asyncio - async def test_handle_non_streaming_allm_passthrough_route_forwards_upstream_headers( - self, monkeypatch - ): + async def test_handle_non_streaming_allm_passthrough_route_forwards_upstream_headers(self, monkeypatch): """The guardrail JSON path must forward upstream response headers (e.g. x-amzn-requestid) alongside the x-litellm-* headers, matching the non-guardrail passthrough path, while dropping length headers that no longer match the rewritten body.""" - processing_obj = ProxyBaseLLMRequestProcessing( - data={"custom_llm_provider": "bedrock"} - ) + processing_obj = ProxyBaseLLMRequestProcessing(data={"custom_llm_provider": "bedrock"}) monkeypatch.setattr( processing_obj, "_has_post_call_guardrails_for_passthrough", @@ -175,14 +171,10 @@ class TestProxyBaseLLMRequestProcessing: assert result.headers["content-length"] == str(len(result.body)) @pytest.mark.asyncio - async def test_handle_event_stream_allm_passthrough_route_forwards_upstream_headers( - self, monkeypatch - ): + async def test_handle_event_stream_allm_passthrough_route_forwards_upstream_headers(self, monkeypatch): """The guardrail event-stream branch must also forward upstream response headers alongside the x-litellm-* headers.""" - processing_obj = ProxyBaseLLMRequestProcessing( - data={"custom_llm_provider": "bedrock"} - ) + processing_obj = ProxyBaseLLMRequestProcessing(data={"custom_llm_provider": "bedrock"}) monkeypatch.setattr( processing_obj, "_has_post_call_guardrails_for_passthrough", @@ -224,15 +216,11 @@ class TestProxyBaseLLMRequestProcessing: assert result.headers["x-litellm-call-id"] == "test-call-id" @pytest.mark.asyncio - async def test_handle_non_streaming_allm_passthrough_route_applies_response_headers_hook( - self, monkeypatch - ): + async def test_handle_non_streaming_allm_passthrough_route_applies_response_headers_hook(self, monkeypatch): """Guardrailed non-streaming passthrough responses must include headers injected by post_call_response_headers_hook, matching the headers a non-guardrailed passthrough response would carry.""" - processing_obj = ProxyBaseLLMRequestProcessing( - data={"custom_llm_provider": "bedrock"} - ) + processing_obj = ProxyBaseLLMRequestProcessing(data={"custom_llm_provider": "bedrock"}) monkeypatch.setattr( processing_obj, "_has_post_call_guardrails_for_passthrough", @@ -251,9 +239,7 @@ class TestProxyBaseLLMRequestProcessing: return kwargs["response"] proxy_logging_obj.post_call_success_hook = fake_post_call_success_hook - proxy_logging_obj.post_call_response_headers_hook = AsyncMock( - return_value={"x-litellm-custom": "from-hook"} - ) + proxy_logging_obj.post_call_response_headers_hook = AsyncMock(return_value={"x-litellm-custom": "from-hook"}) result = await processing_obj._handle_non_streaming_allm_passthrough_route( response=upstream, @@ -2221,6 +2207,52 @@ class TestOverrideOpenAIResponseModel: assert response_obj.model == actual_model_used assert response_obj.model != requested_model + def test_override_model_preserves_model_router_model_for_alias_without_router_in_name(self): + """ + The client sends a model group alias, which carries no model_router/ prefix, so the + name check alone only fires when the operator happened to put "model-router" in the + alias. With the stamp on the response the actual model survives whatever it is named. + """ + from litellm.llms.azure_ai.common_utils import ( + AZURE_MODEL_ROUTER_SELECTED_MODEL_KEY, + ) + + requested_model = "smart-pick" + actual_model_used = "azure_ai/grok-4-1-fast-reasoning" + + response_obj = MagicMock() + response_obj.model = actual_model_used + response_obj._hidden_params = { + "additional_headers": {}, + AZURE_MODEL_ROUTER_SELECTED_MODEL_KEY: actual_model_used, + } + + _override_openai_response_model( + response_obj=response_obj, + requested_model=requested_model, + log_context="test_context", + ) + assert response_obj.model == actual_model_used + + def test_override_model_still_restamps_non_router_alias_without_stamp(self): + """ + Control for the test above: absent the stamp, an ordinary deployment keeps being + restamped to the requested model, so the stamp is doing the work rather than the + preserve branch having gone unconditional. + """ + requested_model = "smart-pick" + + response_obj = MagicMock() + response_obj.model = "azure_ai/grok-4-1-fast-reasoning" + response_obj._hidden_params = {"additional_headers": {}} + + _override_openai_response_model( + response_obj=response_obj, + requested_model=requested_model, + log_context="test_context", + ) + assert response_obj.model == requested_model + def test_override_model_uses_winning_model_for_fastest_response(self): """ Test that when fastest_response batch completion is used with a @@ -2793,9 +2825,7 @@ class TestStreamCloseOnDisconnect: finally: closed.set() - response = _UpstreamClosingStreamingResponse( - body(), media_type="text/event-stream" - ) + response = _UpstreamClosingStreamingResponse(body(), media_type="text/event-stream") async def receive(): await asyncio.Event().wait() @@ -2826,9 +2856,7 @@ class TestStreamCloseOnDisconnect: finally: closed.set() - response = _UpstreamClosingStreamingResponse( - body(), media_type="text/event-stream" - ) + response = _UpstreamClosingStreamingResponse(body(), media_type="text/event-stream") async def receive(): await disconnected.wait() @@ -2899,9 +2927,7 @@ class TestStreamCloseOnDisconnect: finally: inner_closed.set() - response = await create_response( - generator=wrapped(), media_type="text/event-stream", headers={} - ) + response = await create_response(generator=wrapped(), media_type="text/event-stream", headers={}) async def receive(): await asyncio.Event().wait() @@ -3097,9 +3123,7 @@ class TestStreamCloseOnDisconnect: with pytest.raises(_ClientDisconnectedBeforeFirstChunk): await asyncio.wait_for( - _buffer_first_chunk_honoring_disconnect( - AcloseRaises(), request=self._request_that_disconnects() - ), + _buffer_first_chunk_honoring_disconnect(AcloseRaises(), request=self._request_that_disconnects()), timeout=5, ) @@ -3115,9 +3139,7 @@ class TestStreamCloseOnDisconnect: with pytest.raises(_ClientDisconnectedBeforeFirstChunk): await asyncio.wait_for( - _buffer_first_chunk_honoring_disconnect( - blocking_gen(), request=self._request_that_disconnects() - ), + _buffer_first_chunk_honoring_disconnect(blocking_gen(), request=self._request_that_disconnects()), timeout=5, ) assert closed.is_set() @@ -3133,9 +3155,7 @@ class TestHandleLLMApiExceptionRetryAfter: 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=callback_headers or {} - ) + proxy_logging_obj.post_call_response_headers_hook = AsyncMock(return_value=callback_headers or {}) try: await processor._handle_llm_api_exception( @@ -3187,9 +3207,7 @@ class TestHandleLLMApiExceptionRetryAfter: enable_pre_call_checks=False, cooldown_list=[], ) - proxy_exc = await self._invoke( - exc, callback_headers={"retry-after": "", "x-custom": "1"} - ) + proxy_exc = await self._invoke(exc, callback_headers={"retry-after": "", "x-custom": "1"}) assert proxy_exc.headers["retry-after"] == "43" assert proxy_exc.headers["x-custom"] == "1" @@ -3385,9 +3403,7 @@ class TestDisconnectGatherCleanup: return Request(scope={"type": "http", "headers": []}, receive=receive) @pytest.mark.asyncio - async def test_base_process_llm_request_raises_499_on_client_disconnect( - self, monkeypatch - ): + async def test_base_process_llm_request_raises_499_on_client_disconnect(self, monkeypatch): """With cancel_on_disconnect enabled, base_process_llm_request returns 499.""" import asyncio @@ -3416,9 +3432,7 @@ class TestDisconnectGatherCleanup: "common_processing_pre_call_logic", AsyncMock(return_value=({"model": "gemini-2.0-flash"}, mock_logging_obj)), ) - monkeypatch.setattr( - processing_obj, "_has_post_call_guardrails", MagicMock(return_value=False) - ) + monkeypatch.setattr(processing_obj, "_has_post_call_guardrails", MagicMock(return_value=False)) with pytest.raises(HTTPException) as exc_info: await processing_obj.base_process_llm_request( @@ -3436,9 +3450,7 @@ class TestDisconnectGatherCleanup: assert "disconnected" in exc_info.value.detail.lower() @pytest.mark.asyncio - async def test_base_process_llm_request_reraises_cancelled_error_without_client_disconnect( - self, monkeypatch - ): + async def test_base_process_llm_request_reraises_cancelled_error_without_client_disconnect(self, monkeypatch): import asyncio import litellm.proxy.common_request_processing as cpr @@ -3463,9 +3475,7 @@ class TestDisconnectGatherCleanup: "common_processing_pre_call_logic", AsyncMock(return_value=({"model": "gemini-2.0-flash"}, mock_logging_obj)), ) - monkeypatch.setattr( - processing_obj, "_has_post_call_guardrails", MagicMock(return_value=False) - ) + monkeypatch.setattr(processing_obj, "_has_post_call_guardrails", MagicMock(return_value=False)) monkeypatch.setattr( cpr, "route_request", @@ -3526,9 +3536,7 @@ class TestDisconnectGatherCleanup: "common_processing_pre_call_logic", AsyncMock(return_value=({"model": "gemini-2.0-flash"}, mock_logging_obj)), ) - monkeypatch.setattr( - processing_obj, "_has_post_call_guardrails", MagicMock(return_value=False) - ) + monkeypatch.setattr(processing_obj, "_has_post_call_guardrails", MagicMock(return_value=False)) with pytest.raises(HTTPException): await processing_obj.base_process_llm_request( @@ -3579,9 +3587,7 @@ class TestDisconnectGatherCleanup: assert task.done() @pytest.mark.asyncio - async def test_base_process_llm_request_preserves_llm_error_after_gather( - self, monkeypatch - ): + async def test_base_process_llm_request_preserves_llm_error_after_gather(self, monkeypatch): import litellm.proxy.common_request_processing as cpr from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing @@ -3610,9 +3616,7 @@ class TestDisconnectGatherCleanup: "common_processing_pre_call_logic", AsyncMock(return_value=({"model": "gemini-2.0-flash"}, mock_logging_obj)), ) - monkeypatch.setattr( - processing_obj, "_has_post_call_guardrails", MagicMock(return_value=False) - ) + monkeypatch.setattr(processing_obj, "_has_post_call_guardrails", MagicMock(return_value=False)) mock_request = MagicMock(spec=Request) mock_request.is_disconnected = AsyncMock(return_value=False) @@ -3649,19 +3653,13 @@ class TestStreamingClientDisconnectLogging: "litellm_params": {"metadata": {}}, } - recorded = await _record_streaming_client_disconnect_if_needed( - mock_request, request_data - ) + recorded = await _record_streaming_client_disconnect_if_needed(mock_request, request_data) assert recorded is True assert request_data["metadata"]["client_disconnected"] is True + assert request_data["metadata"]["error_information"]["error_code"] == "499" assert ( - request_data["metadata"]["error_information"]["error_code"] == "499" - ) - assert ( - mock_logging_obj.model_call_details["litellm_params"]["metadata"][ - "error_information" - ]["error_code"] + mock_logging_obj.model_call_details["litellm_params"]["metadata"]["error_information"]["error_code"] == "499" ) @@ -3675,9 +3673,7 @@ class TestStreamingClientDisconnectLogging: mock_request.is_disconnected = AsyncMock(return_value=False) request_data = {"metadata": {}} - recorded = await _record_streaming_client_disconnect_if_needed( - mock_request, request_data - ) + recorded = await _record_streaming_client_disconnect_if_needed(mock_request, request_data) assert recorded is False assert "client_disconnected" not in request_data["metadata"] @@ -3702,22 +3698,12 @@ class TestStreamingClientDisconnectLogging: "litellm_params": {"metadata": {}}, } - recorded = await _record_streaming_client_disconnect_if_needed( - mock_request, request_data - ) + recorded = await _record_streaming_client_disconnect_if_needed(mock_request, request_data) assert recorded is True assert request_data["metadata"]["client_disconnected"] is True - assert ( - mock_logging_obj.model_call_details["litellm_params"]["metadata"][ - "client_disconnected" - ] - is True - ) - assert ( - mock_logging_obj.model_call_details["metadata"]["client_disconnected"] - is True - ) + assert mock_logging_obj.model_call_details["litellm_params"]["metadata"]["client_disconnected"] is True + assert mock_logging_obj.model_call_details["metadata"]["client_disconnected"] is True @pytest.mark.asyncio async def test_record_streaming_client_disconnect_handles_none_request_data_metadata(self): @@ -3733,15 +3719,11 @@ class TestStreamingClientDisconnectLogging: "litellm_params": {"metadata": None}, } - recorded = await _record_streaming_client_disconnect_if_needed( - mock_request, request_data - ) + recorded = await _record_streaming_client_disconnect_if_needed(mock_request, request_data) assert recorded is True assert request_data["metadata"]["client_disconnected"] is True - assert ( - request_data["litellm_params"]["metadata"]["client_disconnected"] is True - ) + assert request_data["litellm_params"]["metadata"]["client_disconnected"] is True @pytest.mark.asyncio async def test_apply_client_disconnect_metadata_none_returns_early(self): @@ -3752,9 +3734,7 @@ class TestStreamingClientDisconnectLogging: _apply_client_disconnect_metadata(None) @pytest.mark.asyncio - async def test_finalize_streaming_generator_cleanup_fires_deferred_logging( - self, monkeypatch - ): + async def test_finalize_streaming_generator_cleanup_fires_deferred_logging(self, monkeypatch): from litellm.proxy.common_request_processing import ( ProxyBaseLLMRequestProcessing, ) @@ -3786,9 +3766,7 @@ class TestStreamingClientDisconnectLogging: assert request_data["metadata"]["error_information"]["error_code"] == "499" @pytest.mark.asyncio - async def test_finalize_streaming_generator_cleanup_skips_disconnect_after_completion( - self, monkeypatch - ): + async def test_finalize_streaming_generator_cleanup_skips_disconnect_after_completion(self, monkeypatch): from litellm.proxy.common_request_processing import ( ProxyBaseLLMRequestProcessing, ) @@ -3818,9 +3796,7 @@ class TestStreamingClientDisconnectLogging: assert "client_disconnected" not in request_data["metadata"] @pytest.mark.asyncio - async def test_async_streaming_data_generator_records_499_on_early_aclose( - self, monkeypatch - ): + async def test_async_streaming_data_generator_records_499_on_early_aclose(self, monkeypatch): from litellm.proxy.common_request_processing import ( ProxyBaseLLMRequestProcessing, ) @@ -3835,9 +3811,7 @@ class TestStreamingClientDisconnectLogging: yield {"choices": [{"delta": {"content": " there"}}]} mock_proxy_logging = MagicMock(spec=ProxyLogging) - mock_proxy_logging.async_post_call_streaming_iterator_hook = ( - mock_streaming_iterator - ) + mock_proxy_logging.async_post_call_streaming_iterator_hook = mock_streaming_iterator ProxyLogging._callback_capabilities_cache.clear() mock_request = MagicMock(spec=Request) @@ -3848,9 +3822,7 @@ class TestStreamingClientDisconnectLogging: "model": "gemini-2.0-flash", "metadata": {}, "litellm_params": {"metadata": {}}, - "litellm_logging_obj": MagicMock( - model_call_details={"metadata": {}, "litellm_params": {}} - ), + "litellm_logging_obj": MagicMock(model_call_details={"metadata": {}, "litellm_params": {}}), } gen = ProxyBaseLLMRequestProcessing.async_streaming_data_generator( @@ -3869,6 +3841,8 @@ class TestStreamingClientDisconnectLogging: assert request_data["metadata"]["error_information"]["error_code"] == "499" ProxyLogging._callback_capabilities_cache.clear() + + class TestCancelOnDisconnect: """ Coverage for the opt-in `general_settings.cancel_on_disconnect` flag: @@ -3895,23 +3869,17 @@ class TestCancelOnDisconnect: llm_call = asyncio.get_running_loop().create_future() disconnect_event = asyncio.Event() - await _cancel_llm_call_on_client_disconnect( - request, llm_call, disconnect_event - ) + await _cancel_llm_call_on_client_disconnect(request, llm_call, disconnect_event) assert llm_call.cancelled() assert disconnect_event.is_set() async def test_monitor_is_noop_while_client_stays_connected(self): - request = self._request( - [{"type": "http.request", "body": b"", "more_body": False}] - ) + request = self._request([{"type": "http.request", "body": b"", "more_body": False}]) llm_call = asyncio.get_running_loop().create_future() disconnect_event = asyncio.Event() - monitor = asyncio.create_task( - _cancel_llm_call_on_client_disconnect(request, llm_call, disconnect_event) - ) + monitor = asyncio.create_task(_cancel_llm_call_on_client_disconnect(request, llm_call, disconnect_event)) await asyncio.sleep(0.01) assert not monitor.done() @@ -3930,9 +3898,7 @@ class TestCancelOnDisconnect: llm_call = asyncio.get_running_loop().create_future() disconnect_event = asyncio.Event() - await _cancel_llm_call_on_client_disconnect( - request, llm_call, disconnect_event - ) + await _cancel_llm_call_on_client_disconnect(request, llm_call, disconnect_event) assert not llm_call.cancelled() assert not disconnect_event.is_set() @@ -3947,9 +3913,7 @@ class TestCancelOnDisconnect: with pytest.raises(asyncio.CancelledError): await _await_llm_call_cancelling_on_disconnect(request, llm_call) - async def _drive_base_process_llm_request( - self, monkeypatch, general_settings: dict, llm_call, request: Request - ): + async def _drive_base_process_llm_request(self, monkeypatch, general_settings: dict, llm_call, request: Request): from litellm.proxy._types import UserAPIKeyAuth logging_obj = MagicMock() @@ -3958,9 +3922,7 @@ class TestCancelOnDisconnect: logging_obj._on_deferred_stream_complete = None logging_obj.cost_breakdown = None - processor = ProxyBaseLLMRequestProcessing( - data={"model": "fake-model", "litellm_logging_obj": logging_obj} - ) + processor = ProxyBaseLLMRequestProcessing(data={"model": "fake-model", "litellm_logging_obj": logging_obj}) proxy_logging_obj = MagicMock(spec=ProxyLogging) proxy_logging_obj.during_call_hook = AsyncMock(return_value=None) @@ -3968,9 +3930,7 @@ class TestCancelOnDisconnect: proxy_logging_obj.post_call_success_hook = AsyncMock( side_effect=lambda data, user_api_key_dict, response: response ) - proxy_logging_obj.post_call_response_headers_hook = AsyncMock( - return_value=None - ) + proxy_logging_obj.post_call_response_headers_hook = AsyncMock(return_value=None) async def fake_route_request(**kwargs): return llm_call() @@ -4049,9 +4009,7 @@ class TestCancelOnDisconnect: with pytest.raises(ProxyException) as exc_info: await processor._handle_llm_api_exception( - e=HTTPException( - status_code=499, detail="Client disconnected the request" - ), + e=HTTPException(status_code=499, detail="Client disconnected the request"), user_api_key_dict=UserAPIKeyAuth(api_key="sk-test"), proxy_logging_obj=proxy_logging_obj, ) @@ -4117,7 +4075,9 @@ class TestAllmPassthroughRoutePostCallGuardrails: proxy_logging_obj = ProxyLogging(user_api_key_cache=MagicMock()) monkeypatch.setattr(proxy_logging_obj, "post_call_success_hook", capture_hook) - with patch.object(ProxyBaseLLMRequestProcessing, "_has_post_call_guardrails_for_passthrough", return_value=True): + with patch.object( + ProxyBaseLLMRequestProcessing, "_has_post_call_guardrails_for_passthrough", return_value=True + ): processing_obj = ProxyBaseLLMRequestProcessing(data={}) result = await processing_obj._handle_non_streaming_allm_passthrough_route( response=httpx_response, @@ -4167,7 +4127,9 @@ class TestAllmPassthroughRoutePostCallGuardrails: proxy_logging_obj = ProxyLogging(user_api_key_cache=MagicMock()) monkeypatch.setattr(proxy_logging_obj, "post_call_success_hook", non_dict_hook) - with patch.object(ProxyBaseLLMRequestProcessing, "_has_post_call_guardrails_for_passthrough", return_value=True): + with patch.object( + ProxyBaseLLMRequestProcessing, "_has_post_call_guardrails_for_passthrough", return_value=True + ): processing_obj = ProxyBaseLLMRequestProcessing(data={}) result = await processing_obj._handle_non_streaming_allm_passthrough_route( response=httpx_response, @@ -4205,7 +4167,9 @@ class TestAllmPassthroughRoutePostCallGuardrails: hook_spy = AsyncMock() monkeypatch.setattr(proxy_logging_obj, "post_call_success_hook", hook_spy) - with patch.object(ProxyBaseLLMRequestProcessing, "_has_post_call_guardrails_for_passthrough", return_value=True): + with patch.object( + ProxyBaseLLMRequestProcessing, "_has_post_call_guardrails_for_passthrough", return_value=True + ): processing_obj = ProxyBaseLLMRequestProcessing(data={}) result = await processing_obj._handle_non_streaming_allm_passthrough_route( response=httpx_response, @@ -4246,7 +4210,9 @@ class TestAllmPassthroughRoutePostCallGuardrails: hook_spy = AsyncMock() monkeypatch.setattr(proxy_logging_obj, "post_call_success_hook", hook_spy) - with patch.object(ProxyBaseLLMRequestProcessing, "_has_post_call_guardrails_for_passthrough", return_value=False): + with patch.object( + ProxyBaseLLMRequestProcessing, "_has_post_call_guardrails_for_passthrough", return_value=False + ): processing_obj = ProxyBaseLLMRequestProcessing(data={}) result = await processing_obj._handle_non_streaming_allm_passthrough_route( response=httpx_response, @@ -4358,7 +4324,9 @@ class TestEventStreamAllmPassthroughRoute: "content-length": "99", } - with patch.object(ProxyBaseLLMRequestProcessing, "_has_post_call_guardrails_for_passthrough", return_value=True): + with patch.object( + ProxyBaseLLMRequestProcessing, "_has_post_call_guardrails_for_passthrough", return_value=True + ): processing_obj = ProxyBaseLLMRequestProcessing(data={}) result = await processing_obj._handle_non_streaming_allm_passthrough_route( response=mock_response, @@ -4389,9 +4357,7 @@ class TestAllmPassthroughStreamingProviderGate: de-anonymized. """ - def _build_processing_obj( - self, custom_llm_provider: str, endpoint: str = "" - ) -> ProxyBaseLLMRequestProcessing: + def _build_processing_obj(self, custom_llm_provider: str, endpoint: str = "") -> ProxyBaseLLMRequestProcessing: logging_obj = MagicMock() logging_obj.litellm_call_id = "call-123" logging_obj.cost_breakdown = None @@ -4442,14 +4408,17 @@ class TestAllmPassthroughStreamingProviderGate: processing_obj = self._build_processing_obj("anthropic") chunks = [b"chunk-1", b"chunk-2"] - with patch.object( - ProxyBaseLLMRequestProcessing, - "_has_post_call_guardrails", - return_value=False, - ), patch.object( - ProxyBaseLLMRequestProcessing, - "_has_post_call_guardrails_for_passthrough", - return_value=True, + with ( + patch.object( + ProxyBaseLLMRequestProcessing, + "_has_post_call_guardrails", + return_value=False, + ), + patch.object( + ProxyBaseLLMRequestProcessing, + "_has_post_call_guardrails_for_passthrough", + return_value=True, + ), ): result = await self._run(processing_obj, monkeypatch, chunks) @@ -4458,27 +4427,27 @@ class TestAllmPassthroughStreamingProviderGate: assert streamed == chunks @pytest.mark.asyncio - async def test_bedrock_converse_stream_is_buffered_through_handler( - self, monkeypatch - ): - processing_obj = self._build_processing_obj( - "bedrock", "model/us.amazon.nova-lite-v1:0/converse-stream" - ) + async def test_bedrock_converse_stream_is_buffered_through_handler(self, monkeypatch): + processing_obj = self._build_processing_obj("bedrock", "model/us.amazon.nova-lite-v1:0/converse-stream") chunks = [b"raw-1", b"raw-2"] - with patch.object( - ProxyBaseLLMRequestProcessing, - "_has_post_call_guardrails", - return_value=False, - ), patch.object( - ProxyBaseLLMRequestProcessing, - "_has_post_call_guardrails_for_passthrough", - return_value=True, - ), patch( - "litellm.llms.bedrock.passthrough.guardrail_translation.handler." - "BedrockPassthroughGuardrailHandler.de_anonymize_event_stream", - new=AsyncMock(return_value=b"modified-body"), - ) as mock_handler: + with ( + patch.object( + ProxyBaseLLMRequestProcessing, + "_has_post_call_guardrails", + return_value=False, + ), + patch.object( + ProxyBaseLLMRequestProcessing, + "_has_post_call_guardrails_for_passthrough", + return_value=True, + ), + patch( + "litellm.llms.bedrock.passthrough.guardrail_translation.handler." + "BedrockPassthroughGuardrailHandler.de_anonymize_event_stream", + new=AsyncMock(return_value=b"modified-body"), + ) as mock_handler, + ): result = await self._run(processing_obj, monkeypatch, chunks) assert isinstance(result, Response) @@ -4494,19 +4463,23 @@ class TestAllmPassthroughStreamingProviderGate: ) chunks = [b"raw-1", b"raw-2"] - with patch.object( - ProxyBaseLLMRequestProcessing, - "_has_post_call_guardrails", - return_value=False, - ), patch.object( - ProxyBaseLLMRequestProcessing, - "_has_post_call_guardrails_for_passthrough", - return_value=True, - ), patch( - "litellm.llms.bedrock.passthrough.guardrail_translation.handler." - "BedrockPassthroughGuardrailHandler.de_anonymize_event_stream", - new=AsyncMock(return_value=b"modified-body"), - ) as mock_handler: + with ( + patch.object( + ProxyBaseLLMRequestProcessing, + "_has_post_call_guardrails", + return_value=False, + ), + patch.object( + ProxyBaseLLMRequestProcessing, + "_has_post_call_guardrails_for_passthrough", + return_value=True, + ), + patch( + "litellm.llms.bedrock.passthrough.guardrail_translation.handler." + "BedrockPassthroughGuardrailHandler.de_anonymize_event_stream", + new=AsyncMock(return_value=b"modified-body"), + ) as mock_handler, + ): result = await self._run(processing_obj, monkeypatch, chunks) assert isinstance(result, StreamingResponse) @@ -4528,14 +4501,17 @@ class TestAllmPassthroughStreamingProviderGate: ) chunks = [b"raw-1", b"raw-2"] - with patch.object( - ProxyBaseLLMRequestProcessing, - "_has_post_call_guardrails", - return_value=False, - ), patch.object( - ProxyBaseLLMRequestProcessing, - "_has_post_call_guardrails_for_passthrough", - return_value=False, + with ( + patch.object( + ProxyBaseLLMRequestProcessing, + "_has_post_call_guardrails", + return_value=False, + ), + patch.object( + ProxyBaseLLMRequestProcessing, + "_has_post_call_guardrails_for_passthrough", + return_value=False, + ), ): result = await self._run(processing_obj, monkeypatch, chunks) @@ -4554,14 +4530,17 @@ class TestAllmPassthroughStreamingProviderGate: processing_obj = self._build_processing_obj("anthropic") chunks = [b"chunk-1", b"chunk-2"] - with patch.object( - ProxyBaseLLMRequestProcessing, - "_has_post_call_guardrails", - return_value=False, - ), patch.object( - ProxyBaseLLMRequestProcessing, - "_has_post_call_guardrails_for_passthrough", - return_value=False, + with ( + patch.object( + ProxyBaseLLMRequestProcessing, + "_has_post_call_guardrails", + return_value=False, + ), + patch.object( + ProxyBaseLLMRequestProcessing, + "_has_post_call_guardrails_for_passthrough", + return_value=False, + ), ): result = await self._run(processing_obj, monkeypatch, chunks) @@ -4902,7 +4881,6 @@ class TestResponseCostHeaderForTypedDictResponses: class TestPreCallWithFallbacksOnLocalRateLimit: - @pytest.mark.asyncio async def test_fallback_triggered_on_local_rate_limit(self): from litellm.proxy.common_utils.proxy_rate_limit_error import ProxyRateLimitError @@ -5054,9 +5032,7 @@ class TestPreCallWithFallbacksOnLocalRateLimit: mock_router.fallbacks = [{"gpt-4": ["gpt-3.5-turbo"]}] user_api_key_dict = MagicMock() - user_api_key_dict.router_settings = { - "fallbacks": [{"gpt-4": ["claude-3-haiku"]}] - } + user_api_key_dict.router_settings = {"fallbacks": [{"gpt-4": ["claude-3-haiku"]}]} with patch.object( processor, @@ -5087,9 +5063,7 @@ class TestPreCallWithFallbacksOnLocalRateLimit: from litellm.proxy.common_utils.proxy_rate_limit_error import ProxyRateLimitError from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing - processor = ProxyBaseLLMRequestProcessing( - data={"model": "gpt-4", "disable_fallbacks": True} - ) + processor = ProxyBaseLLMRequestProcessing(data={"model": "gpt-4", "disable_fallbacks": True}) async def mock_pre_call_logic(**kwargs): raise ProxyRateLimitError( @@ -5215,9 +5189,7 @@ class TestPreCallWithFallbacksOnLocalRateLimit: # Real per-key per-model TPM limiter + a key carrying the customer's # `model_tpm_limit` metadata (only the primary is capped). - limiter = _PROXY_MaxParallelRequestsHandler( - internal_usage_cache=InternalUsageCache(DualCache()) - ) + limiter = _PROXY_MaxParallelRequestsHandler(internal_usage_cache=InternalUsageCache(DualCache())) user_api_key_dict = UserAPIKeyAuth( api_key="sk-lit3890", metadata={"model_tpm_limit": {primary_model: 100}}, @@ -5225,10 +5197,7 @@ class TestPreCallWithFallbacksOnLocalRateLimit: # Pre-seed the primary's per-model token counter at the cap so the very # next request trips it. The counter key uses the *hashed* api_key. - counter_key = ( - f"{user_api_key_dict.api_key}::{primary_model}" - f"::{precise_minute}::request_count" - ) + counter_key = f"{user_api_key_dict.api_key}::{primary_model}::{precise_minute}::request_count" await limiter.internal_usage_cache.async_set_cache( key=counter_key, value={"current_requests": 0, "current_tpm": 100, "current_rpm": 0}, @@ -5259,9 +5228,7 @@ class TestPreCallWithFallbacksOnLocalRateLimit: mock_router = MagicMock() mock_router.fallbacks = [{primary_model: [fallback_model]}] - with patch( - "litellm.proxy.hooks.parallel_request_limiter.datetime", _FrozenClock - ): + with patch("litellm.proxy.hooks.parallel_request_limiter.datetime", _FrozenClock): with patch.object( processor, "common_processing_pre_call_logic", @@ -5291,9 +5258,7 @@ class TestPreCallWithFallbacksOnLocalRateLimit: # Sanity-check the premise: the limiter genuinely raises a # ProxyRateLimitError for the capped primary under the frozen clock. - with patch( - "litellm.proxy.hooks.parallel_request_limiter.datetime", _FrozenClock - ): + with patch("litellm.proxy.hooks.parallel_request_limiter.datetime", _FrozenClock): with pytest.raises(ProxyRateLimitError): await limiter.async_pre_call_hook( user_api_key_dict=user_api_key_dict, @@ -5654,16 +5619,12 @@ class TestStreamingClientDisconnectBilling: prompt_tokens=1000, completion_tokens=10, total_tokens=1010, - prompt_tokens_details=PromptTokensDetailsWrapper( - cached_tokens=500 - ), + prompt_tokens_details=PromptTokensDetailsWrapper(cached_tokens=500), ), ) ) - event = await self._bill_and_collect_success_event( - append_openai_style_cached_usage_chunk - ) + event = await self._bill_and_collect_success_event(append_openai_style_cached_usage_chunk) usage = event["response_obj"].usage assert getattr(usage, "cache_read_input_tokens", None) == 500 @@ -6433,9 +6394,7 @@ class TestInjectCostIntoUsageDict: logging_obj.model_call_details["custom_llm_provider"] = "anthropic" assert logging_obj.cost_breakdown is None - model_response = ModelResponse( - usage=Usage(prompt_tokens=3216, completion_tokens=8, total_tokens=3224) - ) + model_response = ModelResponse(usage=Usage(prompt_tokens=3216, completion_tokens=8, total_tokens=3224)) cost = ProxyBaseLLMRequestProcessing._logging_obj_cost_or_none(model_response, logging_obj) assert cost is not None and cost > 0 @@ -6464,9 +6423,7 @@ class TestInjectCostIntoUsageDict: ) existing = logging_obj.cost_breakdown - model_response = ModelResponse( - usage=Usage(prompt_tokens=3216, completion_tokens=8, total_tokens=3224) - ) + model_response = ModelResponse(usage=Usage(prompt_tokens=3216, completion_tokens=8, total_tokens=3224)) ProxyBaseLLMRequestProcessing._logging_obj_cost_or_none(model_response, logging_obj) assert logging_obj.cost_breakdown is existing @@ -6761,9 +6718,7 @@ def test_ttft_keepalive_interval_only_arms_for_a_streaming_request(request_data, @pytest.mark.asyncio @pytest.mark.parametrize("stream_requested, expect_ping", [(True, True), (False, False)]) -async def test_base_process_llm_request_pings_while_the_upstream_call_is_still_running( - stream_requested, expect_ping -): +async def test_base_process_llm_request_pings_while_the_upstream_call_is_still_running(stream_requested, expect_ping): """The wiring, not the helper: every route funnels through this method, and the whole time-to-first-token is spent inside the call it wraps.""" @@ -6909,9 +6864,7 @@ async def test_a_late_failure_is_reported_to_the_failure_hook(): async def record(exc): audited.append(exc) - response = await open_sse_before_first_byte( - slow_failure(), ping_interval_seconds=0.05, on_late_failure=record - ) + response = await open_sse_before_first_byte(slow_failure(), ping_interval_seconds=0.05, on_late_failure=record) collected = await _drain(response) assert [type(exc).__name__ for exc in audited] == ["HTTPException"] @@ -6928,9 +6881,7 @@ async def test_a_failing_audit_hook_never_costs_the_client_its_error_frame(): async def broken_hook(exc): raise RuntimeError("the audit backend is down") - response = await open_sse_before_first_byte( - slow_failure(), ping_interval_seconds=0.05, on_late_failure=broken_hook - ) + response = await open_sse_before_first_byte(slow_failure(), ping_interval_seconds=0.05, on_late_failure=broken_hook) collected = await _drain(response) error_frame = json.loads(collected[-2].decode().removeprefix("data: ").strip()) @@ -6984,9 +6935,7 @@ async def test_base_process_llm_request_audits_a_failure_that_lands_after_its_ke [(0, False), (None, True)], ids=["operator-hard-disabled-this-deployment", "deployment-says-nothing"], ) -async def test_base_process_llm_request_honours_a_deployment_hard_disable( - deployment_keepalive, expect_ping -): +async def test_base_process_llm_request_honours_a_deployment_hard_disable(deployment_keepalive, expect_ping): """`keepalive_seconds: 0` is documented as a disable a request cannot lift. The funnel has to hand its router to the gate for that to hold before the upstream has answered, since no deployment has served the request yet.""" @@ -7032,9 +6981,7 @@ async def test_a_hook_returning_a_replacement_decides_what_the_client_sees(): async def sanitize(exc): return HTTPException(status_code=502, detail="upstream unavailable") - response = await open_sse_before_first_byte( - slow_failure(), ping_interval_seconds=0.05, on_late_failure=sanitize - ) + response = await open_sse_before_first_byte(slow_failure(), ping_interval_seconds=0.05, on_late_failure=sanitize) collected = await _drain(response) error_frame = json.loads(collected[-2].decode().removeprefix("data: ").strip()) @@ -7073,9 +7020,7 @@ async def test_a_hook_that_returns_nothing_leaves_the_real_error_intact(): async def audit_only(exc): return None - response = await open_sse_before_first_byte( - slow_failure(), ping_interval_seconds=0.05, on_late_failure=audit_only - ) + response = await open_sse_before_first_byte(slow_failure(), ping_interval_seconds=0.05, on_late_failure=audit_only) collected = await _drain(response) error_frame = json.loads(collected[-2].decode().removeprefix("data: ").strip()) @@ -7092,9 +7037,7 @@ async def test_a_broken_hook_does_not_replace_the_real_error_with_its_own_bug(): async def broken_hook(exc): raise RuntimeError("the audit backend is down") - response = await open_sse_before_first_byte( - slow_failure(), ping_interval_seconds=0.05, on_late_failure=broken_hook - ) + response = await open_sse_before_first_byte(slow_failure(), ping_interval_seconds=0.05, on_late_failure=broken_hook) collected = await _drain(response) error_frame = json.loads(collected[-2].decode().removeprefix("data: ").strip())