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