From a48ae4760471c37533831a4a6a63bea992b9418a Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 1 Aug 2026 03:03:07 +0000 Subject: [PATCH] test: cover the response transforms and model round-trips touched by the Any cleanup The typing pass swapped dict-unpacking for model_validate in several response paths that had no unit coverage, so codecov flagged the diff. These tests pin the contracts those lines carry rather than just executing them. Video response transforms are driven through a mock httpx.Response and assert the payload parses and the provider id is wrapped so it decodes back to the raw id. The Azure and OpenAI assistants paths use a real SDK client on an httpx mock transport, so the SDK's own parsing runs, and assert the message status defaults to "completed" when the provider omits it and that Thread keeps its declared fields. The SSO default-team helper and the router credential lookup assert their projections directly. Each test fails when the line it covers is reverted. --- .../llms/azure/test_azure_assistants.py | 117 +++++++++++++++++ .../llms/openai/test_openai_assistants.py | 92 ++++++++++++++ .../test_openai_video_transformation.py | 118 +++++++++++++++++- .../proxy/management_endpoints/test_ui_sso.py | 52 +++++++- tests/test_litellm/test_router.py | 53 ++++++++ 5 files changed, 430 insertions(+), 2 deletions(-) create mode 100644 tests/test_litellm/llms/azure/test_azure_assistants.py create mode 100644 tests/test_litellm/llms/openai/test_openai_assistants.py diff --git a/tests/test_litellm/llms/azure/test_azure_assistants.py b/tests/test_litellm/llms/azure/test_azure_assistants.py new file mode 100644 index 00000000000..04b56e479e8 --- /dev/null +++ b/tests/test_litellm/llms/azure/test_azure_assistants.py @@ -0,0 +1,117 @@ +import httpx +import pytest +from openai import AsyncAzureOpenAI + +from litellm.llms.azure.assistants import AzureAssistantsAPI +from litellm.types.llms.openai import OpenAIMessage, Thread + +_MESSAGE_PAYLOAD = { + "id": "msg_123", + "object": "thread.message", + "created_at": 1700000000, + "thread_id": "thread_123", + "role": "assistant", + "status": "in_progress", + "content": [{"type": "text", "text": {"value": "hi", "annotations": []}}], + "metadata": {"origin": "unit-test"}, + "run_id": "run_123", + "assistant_id": "asst_123", + "unexpected_upstream_field": "kept", +} + +_THREAD_PAYLOAD = { + "id": "thread_123", + "object": "thread", + "created_at": 1700000000, + "metadata": {"origin": "unit-test"}, + "unexpected_upstream_field": "kept", +} + +_COMMON_ARGS = { + "api_key": "test-key", + "api_base": "https://test.openai.azure.com", + "api_version": "2024-05-01-preview", + "azure_ad_token": None, + "timeout": 60.0, + "max_retries": 2, +} + + +def _client(payload: dict) -> AsyncAzureOpenAI: + """A real AsyncAzureOpenAI wired to a mock transport, so the SDK's own response + parsing runs and the handler under test receives exactly what production would.""" + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=payload) + + return AsyncAzureOpenAI( + api_key="test-key", + api_version="2024-05-01-preview", + azure_endpoint="https://test.openai.azure.com", + http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)), + ) + + +@pytest.mark.asyncio +async def test_a_add_message_preserves_upstream_message_fields(): + """The returned message is rebuilt from the upstream one, so every field the + provider sent (including ones the SDK model does not declare) must survive.""" + result = await AzureAssistantsAPI().a_add_message( + thread_id="thread_123", + message_data={"role": "user", "content": "hi"}, + client=_client(_MESSAGE_PAYLOAD), + **_COMMON_ARGS, + ) + + assert isinstance(result, OpenAIMessage) + assert result.id == "msg_123" + assert result.thread_id == "thread_123" + assert result.status == "in_progress" + assert result.role == "assistant" + assert result.metadata == {"origin": "unit-test"} + assert result.model_dump()["unexpected_upstream_field"] == "kept" + + +@pytest.mark.asyncio +async def test_a_add_message_defaults_missing_status_to_completed(): + """Some deployments omit `status` on the created message; it is filled in before + the message is handed back so callers never see a status-less message.""" + payload = {k: v for k, v in _MESSAGE_PAYLOAD.items() if k != "status"} + + result = await AzureAssistantsAPI().a_add_message( + thread_id="thread_123", + message_data={"role": "user", "content": "hi"}, + client=_client(payload), + **_COMMON_ARGS, + ) + + assert result.status == "completed" + assert result.id == "msg_123" + assert result.metadata == {"origin": "unit-test"} + + +@pytest.mark.asyncio +async def test_thread_responses_preserve_declared_fields(): + api = AzureAssistantsAPI() + + created = await api.async_create_thread( + metadata={"origin": "unit-test"}, + messages=None, + client=_client(_THREAD_PAYLOAD), + **_COMMON_ARGS, + ) + retrieved = await api.async_get_thread( + thread_id="thread_123", + client=_client(_THREAD_PAYLOAD), + **_COMMON_ARGS, + ) + + for thread in (created, retrieved): + assert isinstance(thread, Thread) + assert thread.id == "thread_123" + assert thread.created_at == 1700000000 + assert thread.object == "thread" + assert thread.metadata == {"origin": "unit-test"} + # LiteLLM's Thread declares its own fields, so anything the provider adds on + # top is dropped rather than carried through. + assert "unexpected_upstream_field" not in thread.model_dump() diff --git a/tests/test_litellm/llms/openai/test_openai_assistants.py b/tests/test_litellm/llms/openai/test_openai_assistants.py new file mode 100644 index 00000000000..36c988749ee --- /dev/null +++ b/tests/test_litellm/llms/openai/test_openai_assistants.py @@ -0,0 +1,92 @@ +import httpx +import pytest +from openai import AsyncOpenAI, OpenAI + +from litellm.llms.openai.openai import OpenAIAssistantsAPI +from litellm.types.llms.openai import Thread + +_THREAD_PAYLOAD = { + "id": "thread_123", + "object": "thread", + "created_at": 1700000000, + "metadata": {"origin": "unit-test"}, + "unexpected_upstream_field": "kept", +} + +_COMMON_ARGS = { + "api_key": "test-key", + "api_base": "https://api.openai.com/v1", + "timeout": 60.0, + "max_retries": 2, + "organization": None, +} + + +def _handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=_THREAD_PAYLOAD) + + +def _async_client() -> AsyncOpenAI: + """A real AsyncOpenAI wired to a mock transport, so the SDK's own response parsing + runs and the handler under test receives exactly what production would.""" + return AsyncOpenAI( + api_key="test-key", + http_client=httpx.AsyncClient(transport=httpx.MockTransport(_handler)), + ) + + +def _sync_client() -> OpenAI: + return OpenAI( + api_key="test-key", + http_client=httpx.Client(transport=httpx.MockTransport(_handler)), + ) + + +def _assert_thread(thread: object) -> None: + assert isinstance(thread, Thread) + assert thread.id == "thread_123" + assert thread.created_at == 1700000000 + assert thread.object == "thread" + assert thread.metadata == {"origin": "unit-test"} + # LiteLLM's Thread declares its own fields, so anything the provider adds on top + # is dropped rather than carried through. + assert "unexpected_upstream_field" not in thread.model_dump() + + +@pytest.mark.asyncio +async def test_async_thread_responses_preserve_declared_fields(): + api = OpenAIAssistantsAPI() + + created = await api.async_create_thread( + metadata={"origin": "unit-test"}, + messages=None, + client=_async_client(), + **_COMMON_ARGS, + ) + retrieved = await api.async_get_thread( + thread_id="thread_123", + client=_async_client(), + **_COMMON_ARGS, + ) + + _assert_thread(created) + _assert_thread(retrieved) + + +def test_sync_thread_responses_preserve_declared_fields(): + api = OpenAIAssistantsAPI() + + created = api.create_thread( + metadata={"origin": "unit-test"}, + messages=None, + client=_sync_client(), + **_COMMON_ARGS, + ) + retrieved = api.get_thread( + thread_id="thread_123", + client=_sync_client(), + **_COMMON_ARGS, + ) + + _assert_thread(created) + _assert_thread(retrieved) diff --git a/tests/test_litellm/llms/openai/videos/test_openai_video_transformation.py b/tests/test_litellm/llms/openai/videos/test_openai_video_transformation.py index 1c183c29398..7d0df6f6542 100644 --- a/tests/test_litellm/llms/openai/videos/test_openai_video_transformation.py +++ b/tests/test_litellm/llms/openai/videos/test_openai_video_transformation.py @@ -2,10 +2,15 @@ import io import os import httpx +import pytest from litellm.llms.openai.videos.transformation import OpenAIVideoConfig from litellm.types.router import GenericLiteLLMParams -from litellm.types.videos.utils import encode_character_id_with_provider +from litellm.types.videos.utils import ( + decode_video_id_with_provider, + encode_character_id_with_provider, + extract_original_video_id, +) def test_video_content_request_encodes_video_id_path_segment(): @@ -145,3 +150,114 @@ def test_video_create_response_parses_payload_with_unknown_fields(): assert video_obj.id == "video_123" assert video_obj.seconds == "8" assert video_obj.usage == {"duration_seconds": 8.0} + + +def _video_payload(video_id: str = "video_123", **overrides: object) -> dict: + payload = { + "id": video_id, + "object": "video", + "status": "completed", + "seconds": "8", + "size": "1280x720", + "unexpected_upstream_field": {"nested": True}, + } + payload.update(overrides) + return payload + + +def _json_response(payload: dict) -> httpx.Response: + return httpx.Response( + 200, + json=payload, + request=httpx.Request("POST", "https://api.openai.com/v1/videos"), + ) + + +@pytest.mark.parametrize( + "method_name", + [ + "transform_video_remix_response", + "transform_video_status_retrieve_response", + "transform_video_edit_response", + "transform_video_extension_response", + ], +) +def test_video_response_transforms_parse_payload_and_wrap_id(method_name): + """Every /videos response transform parses the upstream body into a VideoObject + and swaps in a LiteLLM-managed id that decodes back to the provider's id.""" + config = OpenAIVideoConfig() + transform = getattr(config, method_name) + + video_obj = transform( + raw_response=_json_response(_video_payload()), + logging_obj=None, + custom_llm_provider="openai", + ) + + assert video_obj.status == "completed" + assert video_obj.seconds == "8" + assert video_obj.size == "1280x720" + assert video_obj.id != "video_123" + assert extract_original_video_id(video_obj.id) == "video_123" + assert decode_video_id_with_provider(video_obj.id)["custom_llm_provider"] == "openai" + + +@pytest.mark.parametrize( + "method_name", + [ + "transform_video_remix_response", + "transform_video_status_retrieve_response", + "transform_video_edit_response", + "transform_video_extension_response", + ], +) +def test_video_response_transforms_leave_id_raw_without_provider(method_name): + config = OpenAIVideoConfig() + transform = getattr(config, method_name) + + video_obj = transform( + raw_response=_json_response(_video_payload()), + logging_obj=None, + ) + + assert video_obj.id == "video_123" + + +def test_video_delete_response_parses_payload_without_wrapping_id(): + """Delete returns the deleted object as-is; it must still parse, and it must not + re-encode the id (the caller already holds the managed one).""" + config = OpenAIVideoConfig() + + video_obj = config.transform_video_delete_response( + raw_response=_json_response(_video_payload(status="deleted")), + logging_obj=None, + ) + + assert video_obj.id == "video_123" + assert video_obj.status == "deleted" + + +@pytest.mark.parametrize( + "method_name", + ["transform_video_create_character_response", "transform_video_get_character_response"], +) +def test_character_response_transforms_parse_payload(method_name): + config = OpenAIVideoConfig() + transform = getattr(config, method_name) + + character = transform( + raw_response=_json_response( + { + "id": "char_123", + "object": "character", + "created_at": 1700000000, + "name": "hero", + "unexpected_upstream_field": "ignored", + } + ), + logging_obj=None, + ) + + assert character.id == "char_123" + assert character.name == "hero" + assert character.created_at == 1700000000 diff --git a/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py b/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py index 795b7cd5a9e..e5003a082d5 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py +++ b/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py @@ -15,7 +15,7 @@ sys.path.insert( ) # Adds the parent directory to the system path import litellm -from litellm.proxy._types import LiteLLM_UserTable, NewUserResponse +from litellm.proxy._types import LiteLLM_UserTable, NewTeamRequest, NewUserResponse from litellm.proxy.auth.handle_jwt import JWTHandler from litellm.proxy.management_endpoints.sso import CustomMicrosoftSSO from litellm.proxy.management_endpoints.types import CustomOpenID @@ -7905,3 +7905,53 @@ class TestPersistReturnToCookieSharedHelper: resp = Response() _persist_return_to_cookie(resp, "https://cp.example.com/ui?page=models") assert "litellm_cp_return_to=" in self._cookie(resp) + + +def test_cast_and_deepcopy_default_team_params_from_dict(): + """A dict-shaped litellm.default_team_params is turned into a NewTeamRequest with + the SSO group's id and name layered on top, and the caller's dict is not mutated.""" + default_team_params = {"max_budget": 10.0, "budget_duration": "1d", "models": ["special-gpt-5"]} + + result = SSOAuthenticationHandler._cast_and_deepcopy_litellm_default_team_params( + default_team_params=default_team_params, + team_request=NewTeamRequest(team_id="ignored", team_alias="ignored"), + litellm_team_id="team-abc", + litellm_team_name="Engineering", + ) + + assert isinstance(result, NewTeamRequest) + assert result.team_id == "team-abc" + assert result.team_alias == "Engineering" + assert result.max_budget == 10.0 + assert result.budget_duration == "1d" + assert result.models == ["special-gpt-5"] + assert default_team_params == { + "max_budget": 10.0, + "budget_duration": "1d", + "models": ["special-gpt-5"], + } + + +def test_cast_and_deepcopy_default_team_params_from_model(): + """A DefaultTeamSSOParams-shaped default overlays the incoming request, so the + incoming team id survives while the configured defaults win on the fields they set.""" + litellm.default_team_params = DefaultTeamSSOParams( + max_budget=25.0, budget_duration="7d", models=["special-gpt-5"] + ) + + try: + result = SSOAuthenticationHandler._cast_and_deepcopy_litellm_default_team_params( + default_team_params=litellm.default_team_params, + team_request=NewTeamRequest(team_id="team-abc", team_alias="Engineering"), + litellm_team_id="team-abc", + litellm_team_name="Engineering", + ) + finally: + litellm.default_team_params = None + + assert isinstance(result, NewTeamRequest) + assert result.team_id == "team-abc" + assert result.team_alias == "Engineering" + assert result.max_budget == 25.0 + assert result.budget_duration == "7d" + assert result.models == ["special-gpt-5"] diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py index 46b5ce65c3f..33cd2e0e451 100644 --- a/tests/test_litellm/test_router.py +++ b/tests/test_litellm/test_router.py @@ -6574,3 +6574,56 @@ def test_model_info_is_active_for_environment_matrix(monkeypatch): monkeypatch.delenv("LITELLM_ENVIRONMENT") with pytest.raises(ValueError, match="LITELLM_ENVIRONMENT"): model_info_is_active_for_environment(model_info={"supported_environments": ["production"]}) + + +def test_get_deployment_credentials_returns_credential_fields_only(): + """ + get_deployment_credentials projects a deployment's litellm_params through + CredentialLiteLLMParams, so credential fields survive and routing-only params + (tpm/rpm and friends) are filtered out. + """ + router = litellm.Router( + model_list=[ + { + "model_name": "bedrock-claude-model", + "litellm_params": { + "model": "bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0", + "aws_access_key_id": "test-access-key", + "aws_secret_access_key": "test-secret-key", + "aws_region_name": "us-east-1", + "tpm": 1000, + }, + "model_info": {"id": "deployment-1"}, + } + ], + ) + + credentials = router.get_deployment_credentials(model_id="deployment-1") + + assert credentials is not None + assert credentials["aws_access_key_id"] == "test-access-key" + assert credentials["aws_secret_access_key"] == "test-secret-key" + assert credentials["aws_region_name"] == "us-east-1" + assert "tpm" not in credentials + + +def test_get_deployment_credentials_returns_none_for_blocked_deployment(): + """ + A paused deployment must not hand out credentials, so passthrough file / batch + callers cannot keep using it by resolving credentials directly. + """ + router = litellm.Router( + model_list=[ + { + "model_name": "bedrock-claude-model", + "litellm_params": { + "model": "bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0", + "aws_access_key_id": "test-access-key", + }, + "model_info": {"id": "deployment-1", "blocked": True}, + } + ], + ) + + assert router.get_deployment_credentials(model_id="deployment-1") is None + assert router.get_deployment_credentials(model_id="does-not-exist") is None