diff --git a/litellm/batches/main.py b/litellm/batches/main.py index f977fc03891..60fda3ce83a 100644 --- a/litellm/batches/main.py +++ b/litellm/batches/main.py @@ -293,6 +293,7 @@ def create_batch( or os.getenv("OPENAI_ORGANIZATION", None) or None # default - https://github.com/openai/openai-python/blob/284c1799070c723c6a553337134148a7ab088dd8/openai/util.py#L105 ) + project: Final = optional_params.project or os.getenv("OPENAI_PROJECT", None) or None # set API KEY api_key = ( optional_params.api_key @@ -305,6 +306,7 @@ def create_batch( api_base=api_base, api_key=api_key, organization=organization, + project=project, create_batch_data=_create_batch_request, timeout=timeout, max_retries=optional_params.max_retries, @@ -455,6 +457,7 @@ def _handle_retrieve_batch_providers_without_provider_config( or os.getenv("OPENAI_ORGANIZATION", None) or None # default - https://github.com/openai/openai-python/blob/284c1799070c723c6a553337134148a7ab088dd8/openai/util.py#L105 ) + project: Final = optional_params.project or os.getenv("OPENAI_PROJECT", None) or None # set API KEY api_key = ( optional_params.api_key @@ -469,6 +472,7 @@ def _handle_retrieve_batch_providers_without_provider_config( api_base=api_base, api_key=api_key, organization=organization, + project=project, timeout=timeout, max_retries=optional_params.max_retries, ) @@ -807,6 +811,7 @@ def list_batches( or os.getenv("OPENAI_ORGANIZATION", None) or None # default - https://github.com/openai/openai-python/blob/284c1799070c723c6a553337134148a7ab088dd8/openai/util.py#L105 ) + project: Final = optional_params.project or os.getenv("OPENAI_PROJECT", None) or None response = openai_batches_instance.list_batches( _is_async=_is_async, @@ -815,6 +820,7 @@ def list_batches( api_base=api_base, api_key=api_key, organization=organization, + project=project, timeout=timeout, max_retries=optional_params.max_retries, ) @@ -1004,6 +1010,7 @@ def cancel_batch( organization: Final = ( optional_params.organization or litellm.organization or os.getenv("OPENAI_ORGANIZATION", None) or None ) + project: Final = optional_params.project or os.getenv("OPENAI_PROJECT", None) or None api_key = optional_params.api_key or litellm.api_key or litellm.openai_key or os.getenv("OPENAI_API_KEY") response = openai_batches_instance.cancel_batch( @@ -1012,6 +1019,7 @@ def cancel_batch( api_base=api_base, api_key=api_key, organization=organization, + project=project, timeout=timeout, max_retries=optional_params.max_retries, ) diff --git a/litellm/files/main.py b/litellm/files/main.py index 723784795b0..8116ce93dce 100644 --- a/litellm/files/main.py +++ b/litellm/files/main.py @@ -253,6 +253,7 @@ def create_file( api_base=optional_params.api_base, api_key=optional_params.api_key, organization=optional_params.organization, + project=optional_params.project, ) response = openai_files_instance.create_file( _is_async=_is_async, @@ -261,6 +262,7 @@ def create_file( timeout=timeout, max_retries=optional_params.max_retries, organization=openai_creds.organization, + project=openai_creds.project, create_file_data=_create_file_request, ) elif custom_llm_provider == "azure": @@ -374,6 +376,7 @@ def file_retrieve( api_base=optional_params.api_base, api_key=optional_params.api_key, organization=optional_params.organization, + project=optional_params.project, ) response = openai_files_instance.retrieve_file( file_id=file_id, @@ -383,6 +386,7 @@ def file_retrieve( timeout=timeout, max_retries=optional_params.max_retries, organization=openai_creds.organization, + project=openai_creds.project, ) elif custom_llm_provider == "azure": azure_creds: Final = get_azure_credentials( @@ -555,6 +559,7 @@ def file_delete( api_base=optional_params.api_base, api_key=optional_params.api_key, organization=optional_params.organization, + project=optional_params.project, ) response = openai_files_instance.delete_file( file_id=file_id, @@ -564,6 +569,7 @@ def file_delete( timeout=timeout, max_retries=optional_params.max_retries, organization=openai_creds.organization, + project=openai_creds.project, ) elif custom_llm_provider == "azure": azure_creds: Final = get_azure_credentials( @@ -761,6 +767,7 @@ def file_list( api_base=optional_params.api_base, api_key=optional_params.api_key, organization=optional_params.organization, + project=optional_params.project, ) response = openai_files_instance.list_files( purpose=purpose, @@ -770,6 +777,7 @@ def file_list( timeout=timeout, max_retries=optional_params.max_retries, organization=openai_creds.organization, + project=openai_creds.project, ) elif custom_llm_provider == "azure": azure_creds: Final = get_azure_credentials( @@ -958,6 +966,7 @@ def file_content( api_base=optional_params.api_base, api_key=optional_params.api_key, organization=optional_params.organization, + project=optional_params.project, ) response = openai_files_instance.file_content( _is_async=_is_async, @@ -967,6 +976,7 @@ def file_content( timeout=timeout, max_retries=optional_params.max_retries, organization=openai_creds.organization, + project=openai_creds.project, ) elif custom_llm_provider == "azure": azure_creds: Final = get_azure_credentials( @@ -1077,6 +1087,7 @@ def file_content_streaming( api_base=optional_params.api_base, api_key=optional_params.api_key, organization=optional_params.organization, + project=optional_params.project, ) response = openai_files_instance.file_content_streaming( _is_async=_is_async, @@ -1086,6 +1097,7 @@ def file_content_streaming( timeout=timeout, max_retries=optional_params.max_retries, organization=openai_creds.organization, + project=openai_creds.project, chunk_size=chunk_size, client=client if isinstance(client, (OpenAI, AsyncOpenAI)) else None, ) diff --git a/litellm/llms/openai/common_utils.py b/litellm/llms/openai/common_utils.py index b47edee9976..e95931f8d37 100644 --- a/litellm/llms/openai/common_utils.py +++ b/litellm/llms/openai/common_utils.py @@ -353,12 +353,14 @@ class OpenAICredentials(NamedTuple): api_base: str api_key: str | None organization: str | None + project: str | None = None def get_openai_credentials( api_base: str | None = None, api_key: str | None = None, organization: str | None = None, + project: str | None = None, ) -> OpenAICredentials: """Resolve OpenAI credentials from params, litellm globals, and env vars.""" resolved_api_base: Final = ( @@ -369,9 +371,11 @@ def get_openai_credentials( or "https://api.openai.com/v1" ) resolved_organization = organization or litellm.organization or os.getenv("OPENAI_ORGANIZATION", None) or None + resolved_project: Final = project or os.getenv("OPENAI_PROJECT", None) or None resolved_api_key: Final = api_key or litellm.api_key or litellm.openai_key or os.getenv("OPENAI_API_KEY") return OpenAICredentials( api_base=resolved_api_base, api_key=resolved_api_key, organization=resolved_organization, + project=resolved_project, ) diff --git a/litellm/llms/openai/openai.py b/litellm/llms/openai/openai.py index d6340d182ae..16bcb18ccc4 100644 --- a/litellm/llms/openai/openai.py +++ b/litellm/llms/openai/openai.py @@ -1689,6 +1689,7 @@ class OpenAIFilesAPI(BaseLLM): timeout: float | httpx.Timeout, max_retries: int | None, organization: str | None, + project: str | None = None, client: OpenAI | AsyncOpenAI | None = None, _is_async: bool = False, ) -> OpenAI | AsyncOpenAI | None: @@ -1729,6 +1730,7 @@ class OpenAIFilesAPI(BaseLLM): timeout: float | httpx.Timeout, max_retries: int | None, organization: str | None, + project: str | None = None, client: OpenAI | AsyncOpenAI | None = None, ) -> OpenAIFileObject | Coroutine[None, None, OpenAIFileObject]: openai_client: Final[OpenAI | AsyncOpenAI | None] = self.get_openai_client( @@ -1737,6 +1739,7 @@ class OpenAIFilesAPI(BaseLLM): timeout=timeout, max_retries=max_retries, organization=organization, + project=project, client=client, _is_async=_is_async, ) @@ -1771,6 +1774,7 @@ class OpenAIFilesAPI(BaseLLM): timeout: float | httpx.Timeout, max_retries: int | None, organization: str | None, + project: str | None = None, client: OpenAI | AsyncOpenAI | None = None, ) -> HttpxBinaryResponseContent | Coroutine[None, None, HttpxBinaryResponseContent]: openai_client: Final[OpenAI | AsyncOpenAI | None] = self.get_openai_client( @@ -1779,6 +1783,7 @@ class OpenAIFilesAPI(BaseLLM): timeout=timeout, max_retries=max_retries, organization=organization, + project=project, client=client, _is_async=_is_async, ) @@ -1835,6 +1840,7 @@ class OpenAIFilesAPI(BaseLLM): timeout: float | httpx.Timeout, max_retries: int | None, organization: str | None, + project: str | None = None, chunk_size: int = 1024 * 1024, client: OpenAI | AsyncOpenAI | None = None, ) -> FileContentStreamingResult: @@ -1844,6 +1850,7 @@ class OpenAIFilesAPI(BaseLLM): timeout=timeout, max_retries=max_retries, organization=organization, + project=project, client=client, _is_async=_is_async, ) @@ -1899,6 +1906,7 @@ class OpenAIFilesAPI(BaseLLM): timeout: float | httpx.Timeout, max_retries: int | None, organization: str | None, + project: str | None = None, client: OpenAI | AsyncOpenAI | None = None, ): openai_client: Final[OpenAI | AsyncOpenAI | None] = self.get_openai_client( @@ -1907,6 +1915,7 @@ class OpenAIFilesAPI(BaseLLM): timeout=timeout, max_retries=max_retries, organization=organization, + project=project, client=client, _is_async=_is_async, ) @@ -1945,6 +1954,7 @@ class OpenAIFilesAPI(BaseLLM): timeout: float | httpx.Timeout, max_retries: int | None, organization: str | None, + project: str | None = None, client: OpenAI | AsyncOpenAI | None = None, ): openai_client: Final[OpenAI | AsyncOpenAI | None] = self.get_openai_client( @@ -1953,6 +1963,7 @@ class OpenAIFilesAPI(BaseLLM): timeout=timeout, max_retries=max_retries, organization=organization, + project=project, client=client, _is_async=_is_async, ) @@ -1993,6 +2004,7 @@ class OpenAIFilesAPI(BaseLLM): timeout: float | httpx.Timeout, max_retries: int | None, organization: str | None, + project: str | None = None, purpose: str | None = None, client: OpenAI | AsyncOpenAI | None = None, ): @@ -2002,6 +2014,7 @@ class OpenAIFilesAPI(BaseLLM): timeout=timeout, max_retries=max_retries, organization=organization, + project=project, client=client, _is_async=_is_async, ) @@ -2047,6 +2060,7 @@ class OpenAIBatchesAPI(BaseLLM): timeout: float | httpx.Timeout, max_retries: int | None, organization: str | None, + project: str | None = None, client: OpenAI | AsyncOpenAI | None = None, _is_async: bool = False, ) -> OpenAI | AsyncOpenAI | None: @@ -2087,6 +2101,7 @@ class OpenAIBatchesAPI(BaseLLM): timeout: float | httpx.Timeout, max_retries: int | None, organization: str | None, + project: str | None = None, client: OpenAI | AsyncOpenAI | None = None, ) -> LiteLLMBatch | Coroutine[None, None, LiteLLMBatch]: openai_client: Final[OpenAI | AsyncOpenAI | None] = self.get_openai_client( @@ -2095,6 +2110,7 @@ class OpenAIBatchesAPI(BaseLLM): timeout=timeout, max_retries=max_retries, organization=organization, + project=project, client=client, _is_async=_is_async, ) @@ -2131,6 +2147,7 @@ class OpenAIBatchesAPI(BaseLLM): timeout: float | httpx.Timeout, max_retries: int | None, organization: str | None, + project: str | None = None, client: OpenAI | None = None, ): openai_client: Final[OpenAI | AsyncOpenAI | None] = self.get_openai_client( @@ -2139,6 +2156,7 @@ class OpenAIBatchesAPI(BaseLLM): timeout=timeout, max_retries=max_retries, organization=organization, + project=project, client=client, _is_async=_is_async, ) @@ -2174,6 +2192,7 @@ class OpenAIBatchesAPI(BaseLLM): timeout: float | httpx.Timeout, max_retries: int | None, organization: str | None, + project: str | None = None, client: OpenAI | None = None, ): openai_client: Final[OpenAI | AsyncOpenAI | None] = self.get_openai_client( @@ -2182,6 +2201,7 @@ class OpenAIBatchesAPI(BaseLLM): timeout=timeout, max_retries=max_retries, organization=organization, + project=project, client=client, _is_async=_is_async, ) @@ -2221,6 +2241,7 @@ class OpenAIBatchesAPI(BaseLLM): timeout: float | httpx.Timeout, max_retries: int | None, organization: str | None, + project: str | None = None, after: str | None = None, limit: int | None = None, client: OpenAI | None = None, @@ -2231,6 +2252,7 @@ class OpenAIBatchesAPI(BaseLLM): timeout=timeout, max_retries=max_retries, organization=organization, + project=project, client=client, _is_async=_is_async, ) diff --git a/litellm/proxy/batches_endpoints/endpoints.py b/litellm/proxy/batches_endpoints/endpoints.py index f6c86d75169..461a9f0019b 100644 --- a/litellm/proxy/batches_endpoints/endpoints.py +++ b/litellm/proxy/batches_endpoints/endpoints.py @@ -37,6 +37,7 @@ from litellm.proxy.common_request_processing import ( from litellm.proxy.common_utils.callback_utils import sanitize_openai_provider_metadata from litellm.proxy.common_utils.http_parsing_utils import _read_request_body from litellm.proxy.common_utils.openai_endpoint_utils import ( + apply_openai_project_to_data, get_custom_llm_provider_from_request_headers, get_custom_llm_provider_from_request_query, ) @@ -268,6 +269,7 @@ async def create_batch( "Request received by LiteLLM:\n%s", json.dumps(data, indent=4), ) + apply_openai_project_to_data(data=data, request=request, general_settings=general_settings) base_llm_response_processor: Final = ProxyBaseLLMRequestProcessing(data=data) ( data, @@ -596,6 +598,7 @@ async def retrieve_batch( proxy_config=proxy_config, route_type="aretrieve_batch", ) + apply_openai_project_to_data(data=data, request=request, general_settings=general_settings) unified_model_id: Final = get_model_id_from_unified_batch_id(unified_batch_id) if unified_batch_id else None if unified_model_id is not None: @@ -899,6 +902,8 @@ async def list_batches( proxy_config=proxy_config, route_type="alist_batches", ) + data = dict(data) # mutable-ok: the project resolution below drops or sets `project` + apply_openai_project_to_data(data=data, request=request, general_settings=general_settings) # Try to use managed objects table for listing batches (returns encoded IDs). managed_files_obj: Final = proxy_logging_obj.get_proxy_hook("managed_files") @@ -1093,6 +1098,7 @@ async def cancel_batch( proxy_config=proxy_config, route_type="acancel_batch", ) + apply_openai_project_to_data(data=data, request=request, general_settings=general_settings) # Include original request and headers in the data data = await add_litellm_data_to_request( diff --git a/litellm/proxy/common_utils/openai_endpoint_utils.py b/litellm/proxy/common_utils/openai_endpoint_utils.py index f85bbf3b380..57dc5ab786a 100644 --- a/litellm/proxy/common_utils/openai_endpoint_utils.py +++ b/litellm/proxy/common_utils/openai_endpoint_utils.py @@ -79,3 +79,35 @@ def get_custom_llm_provider_from_request_headers(request: Request) -> str | None if "custom-llm-provider" in request.headers: return request.headers["custom-llm-provider"] return None + + +def apply_openai_project_to_data( + data: dict, + request: Request, + general_settings: dict | None = None, +) -> None: + """ + Resolve the OpenAI `project` for a files/batches request and store it in `data`. + + A caller can ask for a project with the `OpenAI-Project` header or a `project` + field in the request body / multipart form. The proxy holds one OpenAI credential + that reaches every project under it, so a caller-supplied project is a credential + selector: it is only forwarded when the admin opted in with + `general_settings: forward_openai_project: true`, mirroring `forward_openai_org_id`. + Without the opt-in both inputs are dropped and the deployment credential or the + server-side `OPENAI_PROJECT` keeps control. + + Every files/batches handler calls this once so a file or batch created in a project + can still be retrieved, listed, cancelled or deleted through the proxy afterwards. + """ + if not isinstance(general_settings, dict) or general_settings.get("forward_openai_project") is not True: + data.pop("project", None) + return + header_project: Final[str | None] = request.headers.get("OpenAI-Project") + body_value: Final[object] = data.get("project") + body_project: Final[str | None] = body_value if isinstance(body_value, str) else None + resolved_project: Final[str | None] = header_project or body_project or None + if resolved_project is None: + data.pop("project", None) + return + data["project"] = resolved_project diff --git a/litellm/proxy/openai_files_endpoints/files_endpoints.py b/litellm/proxy/openai_files_endpoints/files_endpoints.py index f5e62da5962..3f8e02ff28b 100644 --- a/litellm/proxy/openai_files_endpoints/files_endpoints.py +++ b/litellm/proxy/openai_files_endpoints/files_endpoints.py @@ -47,6 +47,7 @@ from litellm.proxy.common_utils.http_parsing_utils import ( extract_nested_form_metadata, ) from litellm.proxy.common_utils.openai_endpoint_utils import ( + apply_openai_project_to_data, get_custom_llm_provider_from_request_body, get_custom_llm_provider_from_request_headers, get_custom_llm_provider_from_request_query, @@ -552,6 +553,7 @@ async def create_file( custom_llm_provider: str = Form(default="openai"), file: UploadFile = File(...), litellm_metadata: str | None = Form(default=None), + project: str | None = Form(default=None), passthrough: bool = Form(default=False), user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), ): @@ -758,6 +760,10 @@ async def create_file( proxy_config=proxy_config, ) + if project is not None: + data["project"] = project + apply_openai_project_to_data(data=data, request=request, general_settings=general_settings) + uploaded_file_info: Final[UploadedFileInfo] = { "filename": file.filename, "content_type": file.content_type, @@ -993,6 +999,7 @@ async def get_file_content( proxy_config=proxy_config, route_type="afile_content", ) + apply_openai_project_to_data(data=data, request=request, general_settings=general_settings) custom_llm_provider: Final = ( provider @@ -1306,6 +1313,7 @@ async def get_file( proxy_config=proxy_config, route_type="afile_retrieve", ) + apply_openai_project_to_data(data=data, request=request, general_settings=general_settings) ## Check for model-based credential routing from litellm.proxy.proxy_server import llm_router @@ -1512,6 +1520,7 @@ async def delete_file( proxy_config=proxy_config, route_type="afile_delete", ) + apply_openai_project_to_data(data=data, request=request, general_settings=general_settings) # Include original request and headers in the data data = await add_litellm_data_to_request( @@ -1719,6 +1728,7 @@ async def list_files( proxy_config=proxy_config, route_type=CallTypes.alist_fine_tuning_jobs.value, ) + apply_openai_project_to_data(data=data, request=request, general_settings=general_settings) response: Any | None = None diff --git a/litellm/types/router.py b/litellm/types/router.py index 2ab1a1185ed..ccd167ea337 100644 --- a/litellm/types/router.py +++ b/litellm/types/router.py @@ -393,6 +393,7 @@ class GenericLiteLLMParams(CredentialLiteLLMParams, CustomPricingLiteLLMParams): max_retries: int | None = None drop_params: bool | str | None = None organization: str | None = None # for openai orgs + project: str | None = None # for openai projects configurable_clientside_auth_params: CONFIGURABLE_CLIENTSIDE_AUTH_PARAMS = None litellm_credential_name: str | None = None provider_affinity_header: str | None = None diff --git a/tests/batches_tests/test_openai_project_passthrough.py b/tests/batches_tests/test_openai_project_passthrough.py new file mode 100644 index 00000000000..cb49f5924a7 --- /dev/null +++ b/tests/batches_tests/test_openai_project_passthrough.py @@ -0,0 +1,169 @@ +# What is this? +## Unit tests: OpenAI `project` passthrough for the Batches and Files APIs. +## https://github.com/BerriAI/litellm/issues/41803 +import os +from unittest.mock import MagicMock, patch + +import pytest + +import litellm +from litellm.llms.openai.common_utils import get_openai_credentials + + +def test_get_openai_credentials_project_param(): + creds = get_openai_credentials(api_key="sk-test", organization="org-abc", project="proj-xyz") + assert creds.organization == "org-abc" + assert creds.project == "proj-xyz" + + +def test_get_openai_credentials_project_env_fallback(monkeypatch): + monkeypatch.setenv("OPENAI_PROJECT", "proj-from-env") + creds = get_openai_credentials(api_key="sk-test") + assert creds.project == "proj-from-env" + + +def test_openai_batches_api_create_batch_forwards_project(): + from litellm.llms.openai.openai import OpenAIBatchesAPI + from litellm.types.utils import LiteLLMBatch + + batch_response = LiteLLMBatch.model_validate( + { + "id": "batch_abc123", + "object": "batch", + "endpoint": "/v1/chat/completions", + "input_file_id": "file-abc123", + "status": "validating", + "completion_window": "24h", + "created_at": 1700000000, + } + ) + with patch("litellm.llms.openai.openai.OpenAI") as mock_openai: + mock_openai.return_value.batches.create.return_value = batch_response + OpenAIBatchesAPI().create_batch( + _is_async=False, + create_batch_data={ + "completion_window": "24h", + "endpoint": "/v1/chat/completions", + "input_file_id": "file-abc123", + }, + api_key="sk-test", + api_base=None, + timeout=600.0, + max_retries=2, + organization="org-abc", + project="proj-xyz", + ) + + kwargs = mock_openai.call_args.kwargs + assert kwargs["project"] == "proj-xyz" + assert kwargs["organization"] == "org-abc" + + +def test_openai_batches_api_create_batch_omits_project_when_none(): + from litellm.llms.openai.openai import OpenAIBatchesAPI + from litellm.types.utils import LiteLLMBatch + + batch_response = LiteLLMBatch.model_validate( + { + "id": "batch_abc123", + "object": "batch", + "endpoint": "/v1/chat/completions", + "input_file_id": "file-abc123", + "status": "validating", + "completion_window": "24h", + "created_at": 1700000000, + } + ) + with patch("litellm.llms.openai.openai.OpenAI") as mock_openai: + mock_openai.return_value.batches.create.return_value = batch_response + OpenAIBatchesAPI().create_batch( + _is_async=False, + create_batch_data={ + "completion_window": "24h", + "endpoint": "/v1/chat/completions", + "input_file_id": "file-abc123", + }, + api_key="sk-test", + api_base=None, + timeout=600.0, + max_retries=2, + organization=None, + project=None, + ) + + kwargs = mock_openai.call_args.kwargs + assert "project" not in kwargs + assert "organization" not in kwargs + + +def test_openai_files_api_retrieve_file_forwards_project(): + from litellm.llms.openai.openai import OpenAIFilesAPI + + with patch("litellm.llms.openai.openai.OpenAI") as mock_openai: + OpenAIFilesAPI().retrieve_file( + _is_async=False, + file_id="file-abc123", + api_base=None, + api_key="sk-test", + timeout=600.0, + max_retries=2, + organization=None, + project="proj-xyz", + ) + + kwargs = mock_openai.call_args.kwargs + assert kwargs["project"] == "proj-xyz" + + +def test_litellm_create_batch_project_param(monkeypatch): + from litellm.batches import main as batches_main + + monkeypatch.delenv("OPENAI_PROJECT", raising=False) + mock_instance = MagicMock() + with patch.object(batches_main, "openai_batches_instance", mock_instance): + litellm.create_batch( + completion_window="24h", + endpoint="/v1/chat/completions", + input_file_id="file-abc123", + custom_llm_provider="openai", + api_key="sk-test", + project="proj-xyz", + ) + + kwargs = mock_instance.create_batch.call_args.kwargs + assert kwargs["project"] == "proj-xyz" + + +def test_litellm_create_batch_project_env_fallback(monkeypatch): + from litellm.batches import main as batches_main + + monkeypatch.setenv("OPENAI_PROJECT", "proj-from-env") + mock_instance = MagicMock() + with patch.object(batches_main, "openai_batches_instance", mock_instance): + litellm.create_batch( + completion_window="24h", + endpoint="/v1/chat/completions", + input_file_id="file-abc123", + custom_llm_provider="openai", + api_key="sk-test", + ) + + kwargs = mock_instance.create_batch.call_args.kwargs + assert kwargs["project"] == "proj-from-env" + + +def test_litellm_file_retrieve_project_param(monkeypatch): + from litellm.files import main as files_main + + monkeypatch.delenv("OPENAI_PROJECT", raising=False) + mock_instance = MagicMock() + with patch.object(files_main, "openai_files_instance", mock_instance): + files_main.file_retrieve( + file_id="file-abc123", + custom_llm_provider="openai", + api_key="sk-test", + project="proj-xyz", + ) + + kwargs = mock_instance.retrieve_file.call_args.kwargs + assert kwargs["project"] == "proj-xyz" diff --git a/tests/test_litellm/proxy/batches_endpoints/test_endpoints.py b/tests/test_litellm/proxy/batches_endpoints/test_endpoints.py index 2d597abf3b8..1fd30a77fcc 100644 --- a/tests/test_litellm/proxy/batches_endpoints/test_endpoints.py +++ b/tests/test_litellm/proxy/batches_endpoints/test_endpoints.py @@ -3269,3 +3269,92 @@ async def test_cancel__executed_batch_rejects_key_without_model_grant(cancel_har factory.assert_not_called() runner.cancel.assert_not_called() cancel_harness.router_acancel.assert_not_called() + + +# =========================================================================== # +# OpenAI `project` forwarding. The project a batch was created in keeps applying +# to retrieve/list/cancel, and a caller can only steer it through the +# `forward_openai_project` opt-in. +# =========================================================================== # + +PROJECT_HEADER = {"OpenAI-Project": "proj_forwarded"} +FORWARD_PROJECT_SETTINGS = {"forward_openai_project": True} +CREATE_BODY = { + "input_file_id": "file-plain", + "endpoint": "/v1/chat/completions", + "completion_window": "24h", +} + + +@pytest.mark.asyncio +async def test_create__openai_project_header_forwarded_when_opted_in(harness, openai_env_creds, monkeypatch): + monkeypatch.setattr(proxy_server, "general_settings", FORWARD_PROJECT_SETTINGS) + set_body(harness, CREATE_BODY) + + await call_create(harness, headers=PROJECT_HEADER) + + assert harness.acreate_kwargs()["project"] == "proj_forwarded" + + +@pytest.mark.asyncio +async def test_create__openai_project_body_value_forwarded_when_opted_in(harness, openai_env_creds, monkeypatch): + monkeypatch.setattr(proxy_server, "general_settings", FORWARD_PROJECT_SETTINGS) + set_body(harness, {**CREATE_BODY, "project": "proj_from_body"}) + + await call_create(harness) + + assert harness.acreate_kwargs()["project"] == "proj_from_body" + + +@pytest.mark.asyncio +async def test_create__openai_project_dropped_without_opt_in(harness, openai_env_creds): + set_body(harness, {**CREATE_BODY, "project": "proj_from_body"}) + + await call_create(harness, headers=PROJECT_HEADER) + + assert "project" not in harness.acreate_kwargs() + + +@pytest.mark.asyncio +async def test_retrieve__openai_project_header_forwarded_when_opted_in( + retrieve_harness, openai_env_creds, monkeypatch +): + """Regression: creation honored the header, retrieval ignored it, so a batch + made inside a project could not be read back through the proxy.""" + monkeypatch.setattr(proxy_server, "general_settings", FORWARD_PROJECT_SETTINGS) + + await call_retrieve(retrieve_harness, "batch-raw-xyz", headers=PROJECT_HEADER) + + assert retrieve_harness.aretrieve_kwargs()["project"] == "proj_forwarded" + + +@pytest.mark.asyncio +async def test_retrieve__openai_project_dropped_without_opt_in(retrieve_harness, openai_env_creds): + await call_retrieve(retrieve_harness, "batch-raw-xyz", headers=PROJECT_HEADER) + + assert "project" not in retrieve_harness.aretrieve_kwargs() + + +@pytest.mark.asyncio +async def test_list__openai_project_header_forwarded_when_opted_in(list_harness, monkeypatch): + monkeypatch.setattr(proxy_server, "general_settings", FORWARD_PROJECT_SETTINGS) + + await call_list(list_harness, headers=PROJECT_HEADER) + + assert list_harness.alist_kwargs()["project"] == "proj_forwarded" + + +@pytest.mark.asyncio +async def test_list__openai_project_dropped_without_opt_in(list_harness): + await call_list(list_harness, headers=PROJECT_HEADER) + + assert "project" not in list_harness.alist_kwargs() + + +@pytest.mark.asyncio +async def test_cancel__openai_project_header_forwarded_when_opted_in(cancel_harness, openai_env_creds, monkeypatch): + monkeypatch.setattr(proxy_server, "general_settings", FORWARD_PROJECT_SETTINGS) + + await call_cancel(cancel_harness, "batch-raw-xyz", headers=PROJECT_HEADER) + + assert cancel_harness.acancel_kwargs()["project"] == "proj_forwarded" diff --git a/tests/test_litellm/proxy/common_utils/test_openai_endpoint_utils.py b/tests/test_litellm/proxy/common_utils/test_openai_endpoint_utils.py index 3e3e89f8117..4f80a9f7d39 100644 --- a/tests/test_litellm/proxy/common_utils/test_openai_endpoint_utils.py +++ b/tests/test_litellm/proxy/common_utils/test_openai_endpoint_utils.py @@ -3,6 +3,7 @@ import copy import pytest from litellm.proxy.common_utils.openai_endpoint_utils import ( + apply_openai_project_to_data, remove_sensitive_info_from_deployment, ) @@ -121,3 +122,84 @@ def test_remove_sensitive_info_from_deployment_with_excluded_keys(): # api_key should still be removed (popped) regardless of excluded_keys assert "api_key" not in sanitized_config["litellm_params"] + + +# --------------------------------------------------------------------------- # +# apply_openai_project_to_data: a caller-supplied OpenAI project selects which +# project the proxy's shared credential acts inside, so it only reaches the +# provider once the admin opted in with `forward_openai_project`. +# --------------------------------------------------------------------------- # + +FORWARDED: dict = {"forward_openai_project": True} + + +class _FakeRequest: + """Stand-in for the FastAPI request; the helper only reads `.headers`.""" + + def __init__(self, headers: dict | None = None): + self.headers = headers or {} + + +def test_openai_project_is_dropped_without_the_forwarding_opt_in(): + data = {"project": "proj_from_client"} + + apply_openai_project_to_data( + data=data, + request=_FakeRequest({"OpenAI-Project": "proj_from_header"}), + general_settings={}, + ) + + assert "project" not in data + + +def test_openai_project_is_dropped_when_general_settings_is_missing(): + data = {"project": "proj_from_client"} + + apply_openai_project_to_data(data=data, request=_FakeRequest()) + + assert "project" not in data + + +def test_openai_project_header_is_forwarded_when_opted_in(): + data: dict = {} + + apply_openai_project_to_data(data=data, request=_FakeRequest({"OpenAI-Project": "proj_a"}), general_settings=FORWARDED) + + assert data["project"] == "proj_a" + + +def test_openai_project_header_wins_over_the_request_body_value(): + data = {"project": "proj_from_body"} + + apply_openai_project_to_data( + data=data, + request=_FakeRequest({"OpenAI-Project": "proj_from_header"}), + general_settings=FORWARDED, + ) + + assert data["project"] == "proj_from_header" + + +def test_openai_project_body_value_is_forwarded_when_opted_in_without_header(): + data = {"project": "proj_from_body"} + + apply_openai_project_to_data(data=data, request=_FakeRequest(), general_settings=FORWARDED) + + assert data["project"] == "proj_from_body" + + +def test_openai_project_ignores_a_non_string_body_value(): + data = {"project": {"nested": "value"}} + + apply_openai_project_to_data(data=data, request=_FakeRequest(), general_settings=FORWARDED) + + assert "project" not in data + + +def test_openai_project_stays_absent_when_no_caller_supplied_one(): + data: dict = {"batch_id": "batch-1"} + + apply_openai_project_to_data(data=data, request=_FakeRequest(), general_settings=FORWARDED) + + assert "project" not in data + assert data == {"batch_id": "batch-1"} diff --git a/tests/test_litellm/proxy/openai_files_endpoint/test_files_endpoint.py b/tests/test_litellm/proxy/openai_files_endpoint/test_files_endpoint.py index 84cf4ea7c32..d3225f9a40c 100644 --- a/tests/test_litellm/proxy/openai_files_endpoint/test_files_endpoint.py +++ b/tests/test_litellm/proxy/openai_files_endpoint/test_files_endpoint.py @@ -5907,3 +5907,164 @@ def test_create_file_passthrough_fails_closed_when_guardrails_would_scan_the_bat assert error["param"] == "passthrough" assert "guardrails" in error["message"] assert forwarded_calls == [] + + +# --------------------------------------------------------------------------- # +# OpenAI `project` forwarding on /v1/files: the form field and the header must +# reach the provider only through the `forward_openai_project` opt-in, and every +# endpoint of the file lifecycle must resolve the same project. +# --------------------------------------------------------------------------- # + +FILE_UPLOAD_CONTENT = b'{"prompt": "Hello", "completion": "Hi"}' + + +def _stub_create_file_route(monkeypatch, captured: dict) -> None: + """Replace the dispatch step so the test can read the forwarded request.""" + import litellm.proxy.proxy_server as ps + from litellm.proxy._types import LitellmUserRoles + from litellm.proxy.openai_files_endpoints import files_endpoints as fe + from litellm.types.llms.openai import OpenAIFileObject + + async def fake_route_create_file(*, _create_file_request, **kwargs): + captured["request"] = dict(_create_file_request) + return OpenAIFileObject( + id="file-abc123", + object="file", + bytes=len(FILE_UPLOAD_CONTENT), + created_at=1234567890, + filename="data.jsonl", + purpose="user_data", + status="uploaded", + ) + + monkeypatch.setattr("litellm.proxy.proxy_server.master_key", None) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None) + monkeypatch.setattr(fe, "route_create_file", fake_route_create_file) + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN, user_id="test-user" + ) + + +def _upload_file(form: dict, headers: dict): + return client.post( + "/v1/files", + files={"file": ("data.jsonl", FILE_UPLOAD_CONTENT, "application/json")}, + data={"purpose": "user_data", **form}, + headers={"Authorization": "Bearer test-key", **headers}, + ) + + +def test_create_file_project_form_field_forwarded_when_opted_in(monkeypatch, llm_router: Router): + import litellm.proxy.proxy_server as ps + + captured: dict = {} + _stub_create_file_route(monkeypatch, captured) + monkeypatch.setattr(ps, "general_settings", {"forward_openai_project": True}) + monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", llm_router) + setup_proxy_logging_object(monkeypatch, llm_router) + + try: + response = _upload_file({"project": "proj_from_form"}, {}) + assert response.status_code == 200, response.text + assert captured["request"]["project"] == "proj_from_form" + + captured.clear() + response = _upload_file({"project": "proj_from_form"}, {"OpenAI-Project": "proj_from_header"}) + assert response.status_code == 200, response.text + assert captured["request"]["project"] == "proj_from_header" + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + + +def test_create_file_drops_client_project_without_opt_in(monkeypatch, llm_router: Router): + import litellm.proxy.proxy_server as ps + + captured: dict = {} + _stub_create_file_route(monkeypatch, captured) + monkeypatch.setattr(ps, "general_settings", {}) + monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", llm_router) + setup_proxy_logging_object(monkeypatch, llm_router) + + try: + response = _upload_file({"project": "proj_from_form"}, {"OpenAI-Project": "proj_from_header"}) + assert response.status_code == 200, response.text + assert "project" not in captured["request"] + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + + +def test_retrieve_file_forwards_project_header_when_opted_in(monkeypatch, llm_router: Router): + """A file uploaded inside a project is only readable with that project.""" + import litellm.proxy.proxy_server as ps + from unittest.mock import AsyncMock + + from litellm.proxy._types import LitellmUserRoles + from litellm.types.llms.openai import OpenAIFileObject + + retrieved = OpenAIFileObject( + id="file-abc123", + object="file", + bytes=0, + created_at=1234567890, + filename="data.jsonl", + purpose="user_data", + status="uploaded", + ) + afile_retrieve = AsyncMock(return_value=retrieved) + monkeypatch.setattr(litellm, "afile_retrieve", afile_retrieve) + monkeypatch.setattr("litellm.proxy.proxy_server.master_key", None) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None) + monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", llm_router) + monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {"forward_openai_project": True}) + setup_proxy_logging_object(monkeypatch, llm_router) + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN, user_id="test-user" + ) + + try: + response = client.get( + "/v1/files/file-abc123", + headers={"Authorization": "Bearer test-key", "OpenAI-Project": "proj_from_header"}, + ) + assert response.status_code == 200, response.text + assert afile_retrieve.call_args.kwargs["project"] == "proj_from_header" + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + + +def test_retrieve_file_drops_project_header_without_opt_in(monkeypatch, llm_router: Router): + import litellm.proxy.proxy_server as ps + from unittest.mock import AsyncMock + + from litellm.proxy._types import LitellmUserRoles + from litellm.types.llms.openai import OpenAIFileObject + + retrieved = OpenAIFileObject( + id="file-abc123", + object="file", + bytes=0, + created_at=1234567890, + filename="data.jsonl", + purpose="user_data", + status="uploaded", + ) + afile_retrieve = AsyncMock(return_value=retrieved) + monkeypatch.setattr(litellm, "afile_retrieve", afile_retrieve) + monkeypatch.setattr("litellm.proxy.proxy_server.master_key", None) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None) + monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", llm_router) + monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {}) + setup_proxy_logging_object(monkeypatch, llm_router) + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN, user_id="test-user" + ) + + try: + response = client.get( + "/v1/files/file-abc123", + headers={"Authorization": "Bearer test-key", "OpenAI-Project": "proj_from_header"}, + ) + assert response.status_code == 200, response.text + assert "project" not in afile_retrieve.call_args.kwargs + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index a2b35547a47..dafe1def1b8 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -25805,6 +25805,8 @@ export interface components { * @default false */ passthrough: boolean; + /** Project */ + project?: string | null; /** Purpose */ purpose: string; /** @@ -25834,6 +25836,8 @@ export interface components { * @default false */ passthrough: boolean; + /** Project */ + project?: string | null; /** Purpose */ purpose: string; /** @@ -25863,6 +25867,8 @@ export interface components { * @default false */ passthrough: boolean; + /** Project */ + project?: string | null; /** Purpose */ purpose: string; /** @@ -33287,6 +33293,8 @@ export interface components { output_cost_per_video_token?: number | null; /** Output Vector Size */ output_vector_size?: number | null; + /** Project */ + project?: string | null; /** Provider Affinity Header */ provider_affinity_header?: string | null; /** Quality Router Config */ @@ -47118,6 +47126,8 @@ export interface components { output_cost_per_video_token?: number | null; /** Output Vector Size */ output_vector_size?: number | null; + /** Project */ + project?: string | null; /** Provider Affinity Header */ provider_affinity_header?: string | null; /** Quality Router Config */