test: merge three stranded twins into the files that shadow them (#37600)

* test: merge three stranded twins into the files that shadow them

The second mirror's last four files each share a filename with a live test, so
the previous commit could not move them. Three of the four turn out to be plain
additions: their classes collide with nothing in the live file, so the tests are
extra coverage that has sat unrun rather than a competing version of anything.

Appending them takes the three files from 156 collected tests to 196, and all
196 pass. The 40 recovered are 13 OCI cases covering key normalization,
credential validation, complete-URL building and image-url transformation, 15
management-endpoint cases covering empty-value handling and the premium check,
and 12 DeepSeek thinking-parameter cases.

One assertion had to change. test_map_reasoning_effort_none_does_not_enable_thinking
asserted that reasoning_effort='none' leaves no thinking key, while the handler
maps it to {'type': 'disabled'} on purpose, documented in map_openai_params as
the OpenAI-style way to ask for thinking off. The test's stated intent holds,
since disabled does not enable anything, so it now asserts the disabled mapping
instead of the key's absence. Two imports moved to module scope for the
appended code, and no live test was touched.

test_discoverable_endpoints.py is the one left. Its twin grew from 1268 lines
to 9434, 25 of its assertions fail against today's code, and only 5 of its 19
tests have no counterpart, so deciding what survives that rewrite is a
judgement about the endpoints rather than a merge. The allowlist now holds
exactly that file and that reasoning.

* test(oci): stop the OCI suite reading credentials from the environment

validate_environment falls back to os.environ for every OCI credential and only
defaults the region when OCI_REGION is unset, so on a machine with OCI
configured the missing-credential test finds credentials it never passed and the
default-region test builds a URL for the ambient region. The suite then passes
or fails depending on who runs it.

A fixture drops the seven OCI variables for the four classes this branch added
and for TestOCIChatConfig, which had the same dependency before any of this and
fails the same way: with OCI_USER and friends exported, two of its cases fail on
origin/litellm_internal_staging today.

  clean env:        83 passed
  ambient OCI env:  83 passed

Same numbers either way, where the pre-existing file gave 68 passed / 2 failed
under the second.
This commit is contained in:
yuneng-jiang 2026-08-20 14:25:23 -07:00 committed by GitHub
parent 861140b755
commit 8a18e24faa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 682 additions and 696 deletions

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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()

View file

@ -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

View file

@ -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)

View file

@ -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()