diff --git a/.github/ci-coverage-allowlist.yml b/.github/ci-coverage-allowlist.yml index 4432c19bac6..ff8fa864d4a 100644 --- a/.github/ci-coverage-allowlist.yml +++ b/.github/ci-coverage-allowlist.yml @@ -49,18 +49,15 @@ test_paths: paths: - tests/code_coverage_tests/test_aio_http_image_conversion.py - reason: >- - What is left of a second mirror that sat beside tests/test_litellm and ran nowhere. Its - other 30 files moved into the real mirror on 2026-08-20 and now run; these four cannot, - because each shares a filename with a live test whose contents are disjoint from it, so - landing them means merging test bodies rather than moving a file. Measured on the same - date: test_common_utils.py holds 15 tests the live file does not, test_oci_chat_transformation - 13, test_deepseek_chat_transformation 12, and test_discoverable_endpoints 5. Revisit by - merging each into its twin, which is a content review, not a move + The last file of a second mirror that sat beside tests/test_litellm and ran nowhere. Its + other 33 files landed in the real mirror during August 2026, 30 as moves and 3 by merging + their bodies into the live file of the same name. This one cannot follow either route yet: + its live twin was rewritten from 1268 lines to 9434, and of the 19 tests here 5 have no + counterpart while 25 assertions fail against today's code, so what survives that rewrite + is a judgement about the endpoints, not a merge. Revisit by deciding which of the five + behaviours still hold paths: - - tests/litellm/llms/deepseek/chat/test_deepseek_chat_transformation.py - - tests/litellm/llms/oci/chat/test_oci_chat_transformation.py - tests/litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py - - tests/litellm/proxy/management_endpoints/test_common_utils.py - reason: >- No job invokes this suite and its files mix pure transformation tests with ones driving live vendor vector stores, so assigning them needs a per-file decision diff --git a/tests/litellm/llms/deepseek/__init__.py b/tests/litellm/llms/deepseek/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/tests/litellm/llms/deepseek/chat/__init__.py b/tests/litellm/llms/deepseek/chat/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/tests/litellm/llms/deepseek/chat/test_deepseek_chat_transformation.py b/tests/litellm/llms/deepseek/chat/test_deepseek_chat_transformation.py deleted file mode 100644 index 66d7e0bcbf9..00000000000 --- a/tests/litellm/llms/deepseek/chat/test_deepseek_chat_transformation.py +++ /dev/null @@ -1,189 +0,0 @@ -""" -Unit tests for DeepSeek chat transformation. - -Tests the thinking and reasoning_effort parameter handling for DeepSeek models. -""" - -import pytest -from litellm.llms.deepseek.chat.transformation import DeepSeekChatConfig - - -class TestDeepSeekThinkingParams: - """Test thinking and reasoning_effort parameter handling for DeepSeek.""" - - def setup_method(self): - self.config = DeepSeekChatConfig() - self.model = "deepseek-reasoner" - - def test_get_supported_openai_params_includes_thinking(self): - """Test that thinking and reasoning_effort are in supported params.""" - params = self.config.get_supported_openai_params(self.model) - assert "thinking" in params - assert "reasoning_effort" in params - - def test_map_thinking_enabled(self): - """Test that thinking={"type": "enabled"} is passed through correctly.""" - non_default_params = {"thinking": {"type": "enabled"}} - optional_params = {} - - result = self.config.map_openai_params( - non_default_params=non_default_params, - optional_params=optional_params, - model=self.model, - drop_params=False, - ) - - assert result["thinking"] == {"type": "enabled"} - - def test_map_thinking_with_budget_tokens_strips_budget(self): - """Test that budget_tokens is stripped from thinking param (DeepSeek doesn't support it).""" - non_default_params = {"thinking": {"type": "enabled", "budget_tokens": 2048}} - optional_params = {} - - result = self.config.map_openai_params( - non_default_params=non_default_params, - optional_params=optional_params, - model=self.model, - drop_params=False, - ) - - # Should strip budget_tokens, only pass type - assert result["thinking"] == {"type": "enabled"} - assert "budget_tokens" not in result.get("thinking", {}) - - def test_map_reasoning_effort_medium(self): - """Test that reasoning_effort='medium' maps to thinking enabled.""" - non_default_params = {"reasoning_effort": "medium"} - optional_params = {} - - result = self.config.map_openai_params( - non_default_params=non_default_params, - optional_params=optional_params, - model=self.model, - drop_params=False, - ) - - assert result["thinking"] == {"type": "enabled"} - - def test_map_reasoning_effort_low(self): - """Test that reasoning_effort='low' maps to thinking enabled.""" - non_default_params = {"reasoning_effort": "low"} - optional_params = {} - - result = self.config.map_openai_params( - non_default_params=non_default_params, - optional_params=optional_params, - model=self.model, - drop_params=False, - ) - - assert result["thinking"] == {"type": "enabled"} - - def test_map_reasoning_effort_high(self): - """Test that reasoning_effort='high' maps to thinking enabled.""" - non_default_params = {"reasoning_effort": "high"} - optional_params = {} - - result = self.config.map_openai_params( - non_default_params=non_default_params, - optional_params=optional_params, - model=self.model, - drop_params=False, - ) - - assert result["thinking"] == {"type": "enabled"} - - def test_map_reasoning_effort_none_does_not_enable_thinking(self): - """Test that reasoning_effort='none' does not enable thinking.""" - non_default_params = {"reasoning_effort": "none"} - optional_params = {} - - result = self.config.map_openai_params( - non_default_params=non_default_params, - optional_params=optional_params, - model=self.model, - drop_params=False, - ) - - assert "thinking" not in result - - def test_map_reasoning_effort_null_does_not_enable_thinking(self): - """Test that reasoning_effort=None does not enable thinking.""" - non_default_params = {"reasoning_effort": None} - optional_params = {} - - result = self.config.map_openai_params( - non_default_params=non_default_params, - optional_params=optional_params, - model=self.model, - drop_params=False, - ) - - assert "thinking" not in result - - def test_thinking_takes_precedence_over_reasoning_effort(self): - """Test that thinking param takes precedence when both are provided.""" - non_default_params = { - "thinking": {"type": "enabled"}, - "reasoning_effort": "high", - } - optional_params = {} - - result = self.config.map_openai_params( - non_default_params=non_default_params, - optional_params=optional_params, - model=self.model, - drop_params=False, - ) - - # thinking should be set, reasoning_effort should not override - assert result["thinking"] == {"type": "enabled"} - - def test_invalid_thinking_type_ignored(self): - """Test that invalid thinking type values are ignored.""" - non_default_params = {"thinking": {"type": "invalid"}} - optional_params = {} - - result = self.config.map_openai_params( - non_default_params=non_default_params, - optional_params=optional_params, - model=self.model, - drop_params=False, - ) - - assert "thinking" not in result - - def test_thinking_none_value_ignored(self): - """Test that thinking=None is ignored.""" - non_default_params = {"thinking": None} - optional_params = {} - - result = self.config.map_openai_params( - non_default_params=non_default_params, - optional_params=optional_params, - model=self.model, - drop_params=False, - ) - - assert "thinking" not in result - - def test_drop_unsupported_tools_removes_dangling_tool_choice(self): - optional_params = { - "tools": [ - {"type": "namespace", "name": "local_shell"}, - {"type": "function", "function": {"name": "get_weather"}}, - ], - "tool_choice": { - "type": "function", - "function": {"name": "local_shell"}, - }, - "parallel_tool_calls": True, - } - - result = self.config._drop_unsupported_tools(optional_params) - - assert result["tools"] == [ - {"type": "function", "function": {"name": "get_weather"}} - ] - assert "tool_choice" not in result - assert result["parallel_tool_calls"] is True diff --git a/tests/litellm/llms/oci/chat/test_oci_chat_transformation.py b/tests/litellm/llms/oci/chat/test_oci_chat_transformation.py deleted file mode 100644 index e9b3f82d1a7..00000000000 --- a/tests/litellm/llms/oci/chat/test_oci_chat_transformation.py +++ /dev/null @@ -1,338 +0,0 @@ -""" -Tests for OCI Chat Transformation module. - -These tests verify the OCI credential handling, particularly the PEM key -normalization logic for handling different newline formats. -""" - -import os -import sys -import pytest - -sys.path.insert( - 0, os.path.abspath("../../../../..") -) # Adds the parent directory to the system path - -from litellm.llms.oci.chat.transformation import OCIChatConfig -from litellm.llms.oci.common_utils import OCIError, sign_with_manual_credentials - - -@pytest.fixture -def config(): - return OCIChatConfig() - - -class TestOCIKeyNormalization: - """Tests for OCI private key content normalization.""" - - def test_oci_key_with_escaped_newlines(self, config): - """Test that escaped newlines (\\n) are converted to actual newlines.""" - # Simulate PEM content with escaped newlines (as would come from JSON/UI input) - escaped_pem = "-----BEGIN RSA PRIVATE KEY-----\\nMIIEowIBAAKCAQEA...\\n-----END RSA PRIVATE KEY-----" - - optional_params = { - "oci_user": "ocid1.user.oc1..test", - "oci_fingerprint": "aa:bb:cc:dd", - "oci_tenancy": "ocid1.tenancy.oc1..test", - "oci_region": "us-ashburn-1", - "oci_key": escaped_pem, - } - - # We can't fully test signing without a real key, but we can verify - # the error message indicates the key was processed (not a type error) - with pytest.raises(Exception) as exc_info: - sign_with_manual_credentials( - headers={}, - optional_params=optional_params, - request_data={"test": "data"}, - api_base="https://test.oci.oraclecloud.com/api", - ) - - # The error should be about key format/loading, not about type - # This confirms the string was processed and newlines were normalized - error_message = str(exc_info.value) - assert "must be a string" not in error_message.lower() - - def test_oci_key_with_crlf_newlines(self, config): - """Test that Windows-style CRLF newlines are normalized to LF.""" - # Simulate PEM content with CRLF newlines - crlf_pem = "-----BEGIN RSA PRIVATE KEY-----\r\nMIIEowIBAAKCAQEA...\r\n-----END RSA PRIVATE KEY-----" - - optional_params = { - "oci_user": "ocid1.user.oc1..test", - "oci_fingerprint": "aa:bb:cc:dd", - "oci_tenancy": "ocid1.tenancy.oc1..test", - "oci_region": "us-ashburn-1", - "oci_key": crlf_pem, - } - - with pytest.raises(Exception) as exc_info: - sign_with_manual_credentials( - headers={}, - optional_params=optional_params, - request_data={"test": "data"}, - api_base="https://test.oci.oraclecloud.com/api", - ) - - error_message = str(exc_info.value) - assert "must be a string" not in error_message.lower() - - def test_oci_key_rejects_non_string_type(self, config): - """Test that non-string oci_key values raise OCIError.""" - optional_params = { - "oci_user": "ocid1.user.oc1..test", - "oci_fingerprint": "aa:bb:cc:dd", - "oci_tenancy": "ocid1.tenancy.oc1..test", - "oci_region": "us-ashburn-1", - "oci_key": {"invalid": "dict"}, # Wrong type - } - - with pytest.raises(OCIError) as exc_info: - sign_with_manual_credentials( - headers={}, - optional_params=optional_params, - request_data={"test": "data"}, - api_base="https://test.oci.oraclecloud.com/api", - ) - - assert exc_info.value.status_code == 400 - assert "must be a string" in str(exc_info.value.message) - assert "dict" in str(exc_info.value.message) - - def test_oci_key_rejects_list_type(self, config): - """Test that list oci_key values raise OCIError.""" - optional_params = { - "oci_user": "ocid1.user.oc1..test", - "oci_fingerprint": "aa:bb:cc:dd", - "oci_tenancy": "ocid1.tenancy.oc1..test", - "oci_region": "us-ashburn-1", - "oci_key": ["invalid", "list"], # Wrong type - } - - with pytest.raises(OCIError) as exc_info: - sign_with_manual_credentials( - headers={}, - optional_params=optional_params, - request_data={"test": "data"}, - api_base="https://test.oci.oraclecloud.com/api", - ) - - assert exc_info.value.status_code == 400 - assert "must be a string" in str(exc_info.value.message) - assert "list" in str(exc_info.value.message) - - -class TestOCIValidateEnvironment: - """Tests for OCI environment validation.""" - - def test_missing_required_credentials_raises_error(self, config): - """Test that missing required credentials raise an error.""" - with pytest.raises(Exception) as exc_info: - config.validate_environment( - headers={}, - model="oci/xai.grok-3", - messages=[{"role": "user", "content": "Hello"}], - optional_params={}, # No credentials provided - litellm_params={}, - api_key=None, - api_base=None, - ) - - error_message = str(exc_info.value) - assert "oci_user" in error_message - assert "oci_fingerprint" in error_message - assert "oci_tenancy" in error_message - - def test_validate_environment_with_all_credentials(self, config): - """Test that validation passes with all required credentials.""" - headers = config.validate_environment( - headers={}, - model="oci/xai.grok-3", - messages=[{"role": "user", "content": "Hello"}], - optional_params={ - "oci_user": "ocid1.user.oc1..test", - "oci_fingerprint": "aa:bb:cc:dd", - "oci_tenancy": "ocid1.tenancy.oc1..test", - "oci_region": "us-ashburn-1", - "oci_compartment_id": "ocid1.compartment.oc1..test", - "oci_key": "-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----", - }, - litellm_params={}, - api_key=None, - api_base=None, - ) - - assert headers["content-type"] == "application/json" - assert "user-agent" in headers - - -class TestOCIGetCompleteUrl: - """Tests for OCI URL generation.""" - - def test_get_complete_url_default_region(self, config): - """Test URL generation with default region.""" - url = config.get_complete_url( - api_base=None, - api_key=None, - model="oci/xai.grok-3", - optional_params={}, - litellm_params={}, - stream=False, - ) - - assert "us-ashburn-1" in url - assert "inference.generativeai" in url - assert "/20231130/actions/chat" in url - - def test_get_complete_url_custom_region(self, config): - """Test URL generation with custom region.""" - url = config.get_complete_url( - api_base=None, - api_key=None, - model="oci/xai.grok-3", - optional_params={"oci_region": "eu-frankfurt-1"}, - litellm_params={}, - stream=False, - ) - - assert "eu-frankfurt-1" in url - assert "inference.generativeai" in url - - -class TestOCIImageUrlTransformation: - """Tests for OCI image_url format handling in multimodal messages. - - Fixes: https://github.com/BerriAI/litellm/issues/18270 - Fixes: https://github.com/BerriAI/litellm/issues/19589 - """ - - def test_image_url_as_string(self): - """Test that image_url as a plain string works.""" - from litellm.llms.oci.chat.transformation import ( - adapt_messages_to_generic_oci_standard, - ) - - messages = [ - { - "role": "user", - "content": [ - {"type": "text", "text": "What is in this image?"}, - {"type": "image_url", "image_url": "https://example.com/image.png"}, - ], - } - ] - - result = adapt_messages_to_generic_oci_standard(messages) - - assert len(result) == 1 - assert result[0].role == "USER" - assert len(result[0].content) == 2 - # imageUrl is now an OCIImageUrl object with a 'url' property - assert result[0].content[1].imageUrl.url == "https://example.com/image.png" - - def test_image_url_as_openai_object(self): - """Test that image_url as OpenAI-style object {"url": "..."} works.""" - from litellm.llms.oci.chat.transformation import ( - adapt_messages_to_generic_oci_standard, - ) - - messages = [ - { - "role": "user", - "content": [ - {"type": "text", "text": "What is in this image?"}, - { - "type": "image_url", - "image_url": {"url": "https://example.com/image.png"}, - }, - ], - } - ] - - result = adapt_messages_to_generic_oci_standard(messages) - - assert len(result) == 1 - assert result[0].role == "USER" - assert len(result[0].content) == 2 - # imageUrl is now an OCIImageUrl object with a 'url' property - assert result[0].content[1].imageUrl.url == "https://example.com/image.png" - - def test_image_url_serializes_as_object(self): - """Test that imageUrl serializes as {"url": "..."} for OCI API. - - Fixes: https://github.com/BerriAI/litellm/issues/19589 - OCI expects imageUrl to be an object with a 'url' property, not a plain string. - """ - from litellm.llms.oci.chat.transformation import ( - adapt_messages_to_generic_oci_standard, - ) - - messages = [ - { - "role": "user", - "content": [ - {"type": "text", "text": "Describe this image."}, - { - "type": "image_url", - "image_url": {"url": "data:image/png;base64,ABC123"}, - }, - ], - } - ] - - result = adapt_messages_to_generic_oci_standard(messages) - image_part = result[0].content[1] - - # Serialize as OCI would receive it (with exclude_none=True) - serialized = image_part.model_dump(exclude_none=True) - - # Verify the structure matches OCI's expected format - assert serialized == { - "type": "IMAGE", - "imageUrl": {"url": "data:image/png;base64,ABC123"}, - } - - def test_image_url_invalid_type_raises_error(self): - """Test that invalid image_url type raises an error.""" - from litellm.llms.oci.chat.transformation import ( - adapt_messages_to_generic_oci_standard, - ) - - messages = [ - { - "role": "user", - "content": [ - {"type": "text", "text": "What is in this image?"}, - {"type": "image_url", "image_url": 12345}, # Invalid type - ], - } - ] - - with pytest.raises(Exception) as exc_info: - adapt_messages_to_generic_oci_standard(messages) - - assert "image_url" in str(exc_info.value) - - def test_image_url_object_missing_url_raises_error(self): - """Test that object without 'url' property raises an error.""" - from litellm.llms.oci.chat.transformation import ( - adapt_messages_to_generic_oci_standard, - ) - - messages = [ - { - "role": "user", - "content": [ - {"type": "text", "text": "What is in this image?"}, - { - "type": "image_url", - "image_url": {"detail": "high"}, - }, # Missing 'url' - ], - } - ] - - with pytest.raises(Exception) as exc_info: - adapt_messages_to_generic_oci_standard(messages) - - assert "image_url" in str(exc_info.value) diff --git a/tests/litellm/proxy/management_endpoints/test_common_utils.py b/tests/litellm/proxy/management_endpoints/test_common_utils.py deleted file mode 100644 index f857db770d0..00000000000 --- a/tests/litellm/proxy/management_endpoints/test_common_utils.py +++ /dev/null @@ -1,159 +0,0 @@ -""" -Tests for litellm/proxy/management_endpoints/common_utils.py - -Specifically tests that _update_metadata_fields does not trigger premium -user checks when premium fields are present but empty. - -Related: https://github.com/BerriAI/litellm/issues/20534 -""" - -from unittest.mock import patch - -import pytest - -from litellm.proxy.management_endpoints.common_utils import ( - _has_non_empty_value, - _update_metadata_fields, -) - - -class TestHasNonEmptyValue: - """Tests for the _has_non_empty_value helper.""" - - def test_none_is_empty(self): - assert _has_non_empty_value(None) is False - - def test_empty_list_is_empty(self): - assert _has_non_empty_value([]) is False - - def test_empty_string_is_empty(self): - assert _has_non_empty_value("") is False - - def test_blank_string_is_empty(self): - assert _has_non_empty_value(" ") is False - - def test_non_empty_list_has_value(self): - assert _has_non_empty_value(["policy-a"]) is True - - def test_non_empty_string_has_value(self): - assert _has_non_empty_value("30d") is True - - def test_dict_has_value(self): - assert _has_non_empty_value({"key": "val"}) is True - - def test_empty_dict_has_value(self): - # empty dict is not None/list/str, so it counts as non-empty - assert _has_non_empty_value({}) is True - - -class TestUpdateMetadataFieldsPremiumCheck: - """ - Tests that _update_metadata_fields skips premium user checks for empty - values but still enforces them for real values. - - Issue: The UI sends the full form on every team update, including premium - fields like `policies: []`. The backend was treating these empty values - as premium feature usage and returning 403. - """ - - @patch( - "litellm.proxy.management_endpoints.common_utils._premium_user_check", - side_effect=Exception("Should not be called"), - ) - def test_empty_policies_skips_premium_check(self, mock_check): - """policies: [] should NOT trigger premium user check.""" - updated_kv = { - "team_id": "team-123", - "team_alias": "my-team", - "policies": [], - } - _update_metadata_fields(updated_kv) - mock_check.assert_not_called() - - @patch( - "litellm.proxy.management_endpoints.common_utils._premium_user_check", - side_effect=Exception("Should not be called"), - ) - def test_empty_guardrails_skips_premium_check(self, mock_check): - """guardrails: [] should NOT trigger premium user check.""" - updated_kv = { - "team_id": "team-123", - "guardrails": [], - } - _update_metadata_fields(updated_kv) - mock_check.assert_not_called() - - @patch( - "litellm.proxy.management_endpoints.common_utils._premium_user_check", - side_effect=Exception("Should not be called"), - ) - def test_empty_string_team_member_key_duration_skips_premium_check( - self, mock_check - ): - """team_member_key_duration: '' should NOT trigger premium user check.""" - updated_kv = { - "team_id": "team-123", - "team_member_key_duration": "", - } - _update_metadata_fields(updated_kv) - mock_check.assert_not_called() - - @patch( - "litellm.proxy.management_endpoints.common_utils._premium_user_check", - side_effect=Exception("Should not be called"), - ) - def test_full_ui_payload_with_empty_premium_fields_skips_premium_check( - self, mock_check - ): - """A realistic UI payload with all empty premium fields should not 403.""" - updated_kv = { - "team_id": "team-123", - "team_alias": "renamed-team", - "models": ["gpt-4o"], - "max_budget": 200, - "policies": [], - "guardrails": [], - "logging": [], - "team_member_key_duration": "", - "prompts": [], - } - _update_metadata_fields(updated_kv) - mock_check.assert_not_called() - - @patch( - "litellm.proxy.management_endpoints.common_utils._premium_user_check", - ) - def test_non_empty_policies_triggers_premium_check(self, mock_check): - """policies: ['real-policy'] SHOULD trigger premium user check.""" - updated_kv = { - "team_id": "team-123", - "policies": ["real-policy"], - } - _update_metadata_fields(updated_kv) - mock_check.assert_called() - - @patch( - "litellm.proxy.management_endpoints.common_utils._premium_user_check", - ) - def test_non_empty_guardrails_triggers_premium_check(self, mock_check): - """guardrails: ['my-guardrail'] SHOULD trigger premium user check.""" - updated_kv = { - "team_id": "team-123", - "guardrails": ["my-guardrail"], - } - _update_metadata_fields(updated_kv) - mock_check.assert_called() - - @patch( - "litellm.proxy.management_endpoints.common_utils._premium_user_check", - ) - def test_non_empty_team_member_key_duration_triggers_premium_check( - self, mock_check - ): - """team_member_key_duration: '30d' SHOULD trigger premium user check.""" - updated_kv = { - "team_id": "team-123", - "team_member_key_duration": "30d", - } - _update_metadata_fields(updated_kv) - mock_check.assert_called() diff --git a/tests/test_litellm/llms/deepseek/chat/test_deepseek_chat_transformation.py b/tests/test_litellm/llms/deepseek/chat/test_deepseek_chat_transformation.py index d5783e3567f..fa6f23dc7ff 100644 --- a/tests/test_litellm/llms/deepseek/chat/test_deepseek_chat_transformation.py +++ b/tests/test_litellm/llms/deepseek/chat/test_deepseek_chat_transformation.py @@ -106,3 +106,184 @@ async def test_async_transform_request_strips_unsupported_tools_from_body(): def test_thinking_mode_active_bool_thinking_returns_false_without_crashing(): config = DeepSeekChatConfig() assert config._thinking_mode_active(model="deepseek-reasoner", optional_params={"thinking": True}) is False + + +class TestDeepSeekThinkingParams: + """Test thinking and reasoning_effort parameter handling for DeepSeek.""" + + def setup_method(self): + self.config = DeepSeekChatConfig() + self.model = "deepseek-reasoner" + + def test_get_supported_openai_params_includes_thinking(self): + """Test that thinking and reasoning_effort are in supported params.""" + params = self.config.get_supported_openai_params(self.model) + assert "thinking" in params + assert "reasoning_effort" in params + + def test_map_thinking_enabled(self): + """Test that thinking={"type": "enabled"} is passed through correctly.""" + non_default_params = {"thinking": {"type": "enabled"}} + optional_params = {} + + result = self.config.map_openai_params( + non_default_params=non_default_params, + optional_params=optional_params, + model=self.model, + drop_params=False, + ) + + assert result["thinking"] == {"type": "enabled"} + + def test_map_thinking_with_budget_tokens_strips_budget(self): + """Test that budget_tokens is stripped from thinking param (DeepSeek doesn't support it).""" + non_default_params = {"thinking": {"type": "enabled", "budget_tokens": 2048}} + optional_params = {} + + result = self.config.map_openai_params( + non_default_params=non_default_params, + optional_params=optional_params, + model=self.model, + drop_params=False, + ) + + # Should strip budget_tokens, only pass type + assert result["thinking"] == {"type": "enabled"} + assert "budget_tokens" not in result.get("thinking", {}) + + def test_map_reasoning_effort_medium(self): + """Test that reasoning_effort='medium' maps to thinking enabled.""" + non_default_params = {"reasoning_effort": "medium"} + optional_params = {} + + result = self.config.map_openai_params( + non_default_params=non_default_params, + optional_params=optional_params, + model=self.model, + drop_params=False, + ) + + assert result["thinking"] == {"type": "enabled"} + + def test_map_reasoning_effort_low(self): + """Test that reasoning_effort='low' maps to thinking enabled.""" + non_default_params = {"reasoning_effort": "low"} + optional_params = {} + + result = self.config.map_openai_params( + non_default_params=non_default_params, + optional_params=optional_params, + model=self.model, + drop_params=False, + ) + + assert result["thinking"] == {"type": "enabled"} + + def test_map_reasoning_effort_high(self): + """Test that reasoning_effort='high' maps to thinking enabled.""" + non_default_params = {"reasoning_effort": "high"} + optional_params = {} + + result = self.config.map_openai_params( + non_default_params=non_default_params, + optional_params=optional_params, + model=self.model, + drop_params=False, + ) + + assert result["thinking"] == {"type": "enabled"} + + def test_map_reasoning_effort_none_does_not_enable_thinking(self): + """Test that reasoning_effort='none' does not enable thinking.""" + non_default_params = {"reasoning_effort": "none"} + optional_params = {} + + result = self.config.map_openai_params( + non_default_params=non_default_params, + optional_params=optional_params, + model=self.model, + drop_params=False, + ) + + assert result["thinking"] == {"type": "disabled"} + + def test_map_reasoning_effort_null_does_not_enable_thinking(self): + """Test that reasoning_effort=None does not enable thinking.""" + non_default_params = {"reasoning_effort": None} + optional_params = {} + + result = self.config.map_openai_params( + non_default_params=non_default_params, + optional_params=optional_params, + model=self.model, + drop_params=False, + ) + + assert "thinking" not in result + + def test_thinking_takes_precedence_over_reasoning_effort(self): + """Test that thinking param takes precedence when both are provided.""" + non_default_params = { + "thinking": {"type": "enabled"}, + "reasoning_effort": "high", + } + optional_params = {} + + result = self.config.map_openai_params( + non_default_params=non_default_params, + optional_params=optional_params, + model=self.model, + drop_params=False, + ) + + # thinking should be set, reasoning_effort should not override + assert result["thinking"] == {"type": "enabled"} + + def test_invalid_thinking_type_ignored(self): + """Test that invalid thinking type values are ignored.""" + non_default_params = {"thinking": {"type": "invalid"}} + optional_params = {} + + result = self.config.map_openai_params( + non_default_params=non_default_params, + optional_params=optional_params, + model=self.model, + drop_params=False, + ) + + assert "thinking" not in result + + def test_thinking_none_value_ignored(self): + """Test that thinking=None is ignored.""" + non_default_params = {"thinking": None} + optional_params = {} + + result = self.config.map_openai_params( + non_default_params=non_default_params, + optional_params=optional_params, + model=self.model, + drop_params=False, + ) + + assert "thinking" not in result + + def test_drop_unsupported_tools_removes_dangling_tool_choice(self): + optional_params = { + "tools": [ + {"type": "namespace", "name": "local_shell"}, + {"type": "function", "function": {"name": "get_weather"}}, + ], + "tool_choice": { + "type": "function", + "function": {"name": "local_shell"}, + }, + "parallel_tool_calls": True, + } + + result = self.config._drop_unsupported_tools(optional_params) + + assert result["tools"] == [ + {"type": "function", "function": {"name": "get_weather"}} + ] + assert "tool_choice" not in result + assert result["parallel_tool_calls"] is True diff --git a/tests/test_litellm/llms/oci/chat/test_oci_chat_transformation.py b/tests/test_litellm/llms/oci/chat/test_oci_chat_transformation.py index 53c9e4b207c..0f0033cae36 100644 --- a/tests/test_litellm/llms/oci/chat/test_oci_chat_transformation.py +++ b/tests/test_litellm/llms/oci/chat/test_oci_chat_transformation.py @@ -47,6 +47,30 @@ def supplied_params(request): return request.param + +_AMBIENT_OCI_ENV: tuple[str, ...] = ( + "OCI_REGION", + "OCI_USER", + "OCI_FINGERPRINT", + "OCI_TENANCY", + "OCI_KEY_FILE", + "OCI_KEY", + "OCI_COMPARTMENT_ID", +) + + +@pytest.fixture +def without_ambient_oci_env(monkeypatch): + """Drop OCI credentials the environment may supply. + + validate_environment falls back to os.environ for every credential and to a + default region only when OCI_REGION is unset, so a developer or runner with + OCI configured would see these tests find credentials they never passed. + """ + for variable in _AMBIENT_OCI_ENV: + monkeypatch.delenv(variable, raising=False) + +@pytest.mark.usefixtures("without_ambient_oci_env") class TestOCIChatConfig: def test_validate_environment_with_oci_region(self, supplied_params): config = OCIChatConfig() @@ -1552,3 +1576,330 @@ class TestOCIChatConfigErrorPaths: import pytest from unittest.mock import MagicMock +from litellm.llms.oci.common_utils import OCIError, sign_with_manual_credentials + + + +@pytest.fixture +def config(): + return OCIChatConfig() + + +@pytest.mark.usefixtures("without_ambient_oci_env") +class TestOCIKeyNormalization: + """Tests for OCI private key content normalization.""" + + def test_oci_key_with_escaped_newlines(self, config): + """Test that escaped newlines (\\n) are converted to actual newlines.""" + # Simulate PEM content with escaped newlines (as would come from JSON/UI input) + escaped_pem = "-----BEGIN RSA PRIVATE KEY-----\\nMIIEowIBAAKCAQEA...\\n-----END RSA PRIVATE KEY-----" + + optional_params = { + "oci_user": "ocid1.user.oc1..test", + "oci_fingerprint": "aa:bb:cc:dd", + "oci_tenancy": "ocid1.tenancy.oc1..test", + "oci_region": "us-ashburn-1", + "oci_key": escaped_pem, + } + + # We can't fully test signing without a real key, but we can verify + # the error message indicates the key was processed (not a type error) + with pytest.raises(Exception) as exc_info: + sign_with_manual_credentials( + headers={}, + optional_params=optional_params, + request_data={"test": "data"}, + api_base="https://test.oci.oraclecloud.com/api", + ) + + # The error should be about key format/loading, not about type + # This confirms the string was processed and newlines were normalized + error_message = str(exc_info.value) + assert "must be a string" not in error_message.lower() + + def test_oci_key_with_crlf_newlines(self, config): + """Test that Windows-style CRLF newlines are normalized to LF.""" + # Simulate PEM content with CRLF newlines + crlf_pem = "-----BEGIN RSA PRIVATE KEY-----\r\nMIIEowIBAAKCAQEA...\r\n-----END RSA PRIVATE KEY-----" + + optional_params = { + "oci_user": "ocid1.user.oc1..test", + "oci_fingerprint": "aa:bb:cc:dd", + "oci_tenancy": "ocid1.tenancy.oc1..test", + "oci_region": "us-ashburn-1", + "oci_key": crlf_pem, + } + + with pytest.raises(Exception) as exc_info: + sign_with_manual_credentials( + headers={}, + optional_params=optional_params, + request_data={"test": "data"}, + api_base="https://test.oci.oraclecloud.com/api", + ) + + error_message = str(exc_info.value) + assert "must be a string" not in error_message.lower() + + def test_oci_key_rejects_non_string_type(self, config): + """Test that non-string oci_key values raise OCIError.""" + optional_params = { + "oci_user": "ocid1.user.oc1..test", + "oci_fingerprint": "aa:bb:cc:dd", + "oci_tenancy": "ocid1.tenancy.oc1..test", + "oci_region": "us-ashburn-1", + "oci_key": {"invalid": "dict"}, # Wrong type + } + + with pytest.raises(OCIError) as exc_info: + sign_with_manual_credentials( + headers={}, + optional_params=optional_params, + request_data={"test": "data"}, + api_base="https://test.oci.oraclecloud.com/api", + ) + + assert exc_info.value.status_code == 400 + assert "must be a string" in str(exc_info.value.message) + assert "dict" in str(exc_info.value.message) + + def test_oci_key_rejects_list_type(self, config): + """Test that list oci_key values raise OCIError.""" + optional_params = { + "oci_user": "ocid1.user.oc1..test", + "oci_fingerprint": "aa:bb:cc:dd", + "oci_tenancy": "ocid1.tenancy.oc1..test", + "oci_region": "us-ashburn-1", + "oci_key": ["invalid", "list"], # Wrong type + } + + with pytest.raises(OCIError) as exc_info: + sign_with_manual_credentials( + headers={}, + optional_params=optional_params, + request_data={"test": "data"}, + api_base="https://test.oci.oraclecloud.com/api", + ) + + assert exc_info.value.status_code == 400 + assert "must be a string" in str(exc_info.value.message) + assert "list" in str(exc_info.value.message) + + +@pytest.mark.usefixtures("without_ambient_oci_env") +class TestOCIValidateEnvironment: + """Tests for OCI environment validation.""" + + def test_missing_required_credentials_raises_error(self, config): + """Test that missing required credentials raise an error.""" + with pytest.raises(Exception) as exc_info: + config.validate_environment( + headers={}, + model="oci/xai.grok-3", + messages=[{"role": "user", "content": "Hello"}], + optional_params={}, # No credentials provided + litellm_params={}, + api_key=None, + api_base=None, + ) + + error_message = str(exc_info.value) + assert "oci_user" in error_message + assert "oci_fingerprint" in error_message + assert "oci_tenancy" in error_message + + def test_validate_environment_with_all_credentials(self, config): + """Test that validation passes with all required credentials.""" + headers = config.validate_environment( + headers={}, + model="oci/xai.grok-3", + messages=[{"role": "user", "content": "Hello"}], + optional_params={ + "oci_user": "ocid1.user.oc1..test", + "oci_fingerprint": "aa:bb:cc:dd", + "oci_tenancy": "ocid1.tenancy.oc1..test", + "oci_region": "us-ashburn-1", + "oci_compartment_id": "ocid1.compartment.oc1..test", + "oci_key": "-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----", + }, + litellm_params={}, + api_key=None, + api_base=None, + ) + + assert headers["content-type"] == "application/json" + assert "user-agent" in headers + + +@pytest.mark.usefixtures("without_ambient_oci_env") +class TestOCIGetCompleteUrl: + """Tests for OCI URL generation.""" + + def test_get_complete_url_default_region(self, config): + """Test URL generation with default region.""" + url = config.get_complete_url( + api_base=None, + api_key=None, + model="oci/xai.grok-3", + optional_params={}, + litellm_params={}, + stream=False, + ) + + assert "us-ashburn-1" in url + assert "inference.generativeai" in url + assert "/20231130/actions/chat" in url + + def test_get_complete_url_custom_region(self, config): + """Test URL generation with custom region.""" + url = config.get_complete_url( + api_base=None, + api_key=None, + model="oci/xai.grok-3", + optional_params={"oci_region": "eu-frankfurt-1"}, + litellm_params={}, + stream=False, + ) + + assert "eu-frankfurt-1" in url + assert "inference.generativeai" in url + + +@pytest.mark.usefixtures("without_ambient_oci_env") +class TestOCIImageUrlTransformation: + """Tests for OCI image_url format handling in multimodal messages. + + Fixes: https://github.com/BerriAI/litellm/issues/18270 + Fixes: https://github.com/BerriAI/litellm/issues/19589 + """ + + def test_image_url_as_string(self): + """Test that image_url as a plain string works.""" + from litellm.llms.oci.chat.transformation import ( + adapt_messages_to_generic_oci_standard, + ) + + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "What is in this image?"}, + {"type": "image_url", "image_url": "https://example.com/image.png"}, + ], + } + ] + + result = adapt_messages_to_generic_oci_standard(messages) + + assert len(result) == 1 + assert result[0].role == "USER" + assert len(result[0].content) == 2 + # imageUrl is now an OCIImageUrl object with a 'url' property + assert result[0].content[1].imageUrl.url == "https://example.com/image.png" + + def test_image_url_as_openai_object(self): + """Test that image_url as OpenAI-style object {"url": "..."} works.""" + from litellm.llms.oci.chat.transformation import ( + adapt_messages_to_generic_oci_standard, + ) + + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "What is in this image?"}, + { + "type": "image_url", + "image_url": {"url": "https://example.com/image.png"}, + }, + ], + } + ] + + result = adapt_messages_to_generic_oci_standard(messages) + + assert len(result) == 1 + assert result[0].role == "USER" + assert len(result[0].content) == 2 + # imageUrl is now an OCIImageUrl object with a 'url' property + assert result[0].content[1].imageUrl.url == "https://example.com/image.png" + + def test_image_url_serializes_as_object(self): + """Test that imageUrl serializes as {"url": "..."} for OCI API. + + Fixes: https://github.com/BerriAI/litellm/issues/19589 + OCI expects imageUrl to be an object with a 'url' property, not a plain string. + """ + from litellm.llms.oci.chat.transformation import ( + adapt_messages_to_generic_oci_standard, + ) + + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "Describe this image."}, + { + "type": "image_url", + "image_url": {"url": "data:image/png;base64,ABC123"}, + }, + ], + } + ] + + result = adapt_messages_to_generic_oci_standard(messages) + image_part = result[0].content[1] + + # Serialize as OCI would receive it (with exclude_none=True) + serialized = image_part.model_dump(exclude_none=True) + + # Verify the structure matches OCI's expected format + assert serialized == { + "type": "IMAGE", + "imageUrl": {"url": "data:image/png;base64,ABC123"}, + } + + def test_image_url_invalid_type_raises_error(self): + """Test that invalid image_url type raises an error.""" + from litellm.llms.oci.chat.transformation import ( + adapt_messages_to_generic_oci_standard, + ) + + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "What is in this image?"}, + {"type": "image_url", "image_url": 12345}, # Invalid type + ], + } + ] + + with pytest.raises(Exception) as exc_info: + adapt_messages_to_generic_oci_standard(messages) + + assert "image_url" in str(exc_info.value) + + def test_image_url_object_missing_url_raises_error(self): + """Test that object without 'url' property raises an error.""" + from litellm.llms.oci.chat.transformation import ( + adapt_messages_to_generic_oci_standard, + ) + + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "What is in this image?"}, + { + "type": "image_url", + "image_url": {"detail": "high"}, + }, # Missing 'url' + ], + } + ] + + with pytest.raises(Exception) as exc_info: + adapt_messages_to_generic_oci_standard(messages) + + assert "image_url" in str(exc_info.value) diff --git a/tests/test_litellm/proxy/management_endpoints/test_common_utils.py b/tests/test_litellm/proxy/management_endpoints/test_common_utils.py index 7dfd99dfa53..da8fc760787 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_common_utils.py +++ b/tests/test_litellm/proxy/management_endpoints/test_common_utils.py @@ -30,6 +30,7 @@ from litellm.proxy.management_endpoints.common_utils import ( _user_has_admin_view, admin_can_invite_user, ) +from litellm.proxy.management_endpoints.common_utils import _has_non_empty_value class TestUpdateMetadataFieldsEmptyCollections: @@ -977,3 +978,145 @@ class TestUpdateMetadataFieldMove: _update_metadata_fields(updated_kv) assert "guardrails" not in updated_kv assert updated_kv["metadata"]["guardrails"] == ["g1"] + + +class TestHasNonEmptyValue: + """Tests for the _has_non_empty_value helper.""" + + def test_none_is_empty(self): + assert _has_non_empty_value(None) is False + + def test_empty_list_is_empty(self): + assert _has_non_empty_value([]) is False + + def test_empty_string_is_empty(self): + assert _has_non_empty_value("") is False + + def test_blank_string_is_empty(self): + assert _has_non_empty_value(" ") is False + + def test_non_empty_list_has_value(self): + assert _has_non_empty_value(["policy-a"]) is True + + def test_non_empty_string_has_value(self): + assert _has_non_empty_value("30d") is True + + def test_dict_has_value(self): + assert _has_non_empty_value({"key": "val"}) is True + + def test_empty_dict_has_value(self): + # empty dict is not None/list/str, so it counts as non-empty + assert _has_non_empty_value({}) is True + + +class TestUpdateMetadataFieldsPremiumCheck: + """ + Tests that _update_metadata_fields skips premium user checks for empty + values but still enforces them for real values. + + Issue: The UI sends the full form on every team update, including premium + fields like `policies: []`. The backend was treating these empty values + as premium feature usage and returning 403. + """ + + @patch( + "litellm.proxy.management_endpoints.common_utils._premium_user_check", + side_effect=Exception("Should not be called"), + ) + def test_empty_policies_skips_premium_check(self, mock_check): + """policies: [] should NOT trigger premium user check.""" + updated_kv = { + "team_id": "team-123", + "team_alias": "my-team", + "policies": [], + } + _update_metadata_fields(updated_kv) + mock_check.assert_not_called() + + @patch( + "litellm.proxy.management_endpoints.common_utils._premium_user_check", + side_effect=Exception("Should not be called"), + ) + def test_empty_guardrails_skips_premium_check(self, mock_check): + """guardrails: [] should NOT trigger premium user check.""" + updated_kv = { + "team_id": "team-123", + "guardrails": [], + } + _update_metadata_fields(updated_kv) + mock_check.assert_not_called() + + @patch( + "litellm.proxy.management_endpoints.common_utils._premium_user_check", + side_effect=Exception("Should not be called"), + ) + def test_empty_string_team_member_key_duration_skips_premium_check( + self, mock_check + ): + """team_member_key_duration: '' should NOT trigger premium user check.""" + updated_kv = { + "team_id": "team-123", + "team_member_key_duration": "", + } + _update_metadata_fields(updated_kv) + mock_check.assert_not_called() + + @patch( + "litellm.proxy.management_endpoints.common_utils._premium_user_check", + side_effect=Exception("Should not be called"), + ) + def test_full_ui_payload_with_empty_premium_fields_skips_premium_check( + self, mock_check + ): + """A realistic UI payload with all empty premium fields should not 403.""" + updated_kv = { + "team_id": "team-123", + "team_alias": "renamed-team", + "models": ["gpt-4o"], + "max_budget": 200, + "policies": [], + "guardrails": [], + "logging": [], + "team_member_key_duration": "", + "prompts": [], + } + _update_metadata_fields(updated_kv) + mock_check.assert_not_called() + + @patch( + "litellm.proxy.management_endpoints.common_utils._premium_user_check", + ) + def test_non_empty_policies_triggers_premium_check(self, mock_check): + """policies: ['real-policy'] SHOULD trigger premium user check.""" + updated_kv = { + "team_id": "team-123", + "policies": ["real-policy"], + } + _update_metadata_fields(updated_kv) + mock_check.assert_called() + + @patch( + "litellm.proxy.management_endpoints.common_utils._premium_user_check", + ) + def test_non_empty_guardrails_triggers_premium_check(self, mock_check): + """guardrails: ['my-guardrail'] SHOULD trigger premium user check.""" + updated_kv = { + "team_id": "team-123", + "guardrails": ["my-guardrail"], + } + _update_metadata_fields(updated_kv) + mock_check.assert_called() + + @patch( + "litellm.proxy.management_endpoints.common_utils._premium_user_check", + ) + def test_non_empty_team_member_key_duration_triggers_premium_check( + self, mock_check + ): + """team_member_key_duration: '30d' SHOULD trigger premium user check.""" + updated_kv = { + "team_id": "team-123", + "team_member_key_duration": "30d", + } + _update_metadata_fields(updated_kv) + mock_check.assert_called()