diff --git a/litellm/batches/main.py b/litellm/batches/main.py index 548cf693ab9..6e2e01954a1 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, ) @@ -809,6 +813,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, @@ -817,6 +822,7 @@ def list_batches( api_base=api_base, api_key=api_key, organization=organization, + project=project, timeout=timeout, max_retries=optional_params.max_retries, ) @@ -1006,6 +1012,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( @@ -1014,6 +1021,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 94fdb41042b..862fa08940a 100644 --- a/litellm/files/main.py +++ b/litellm/files/main.py @@ -250,6 +250,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, @@ -258,6 +259,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": @@ -371,6 +373,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, @@ -380,6 +383,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( @@ -552,6 +556,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, @@ -561,6 +566,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( @@ -758,6 +764,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, @@ -767,6 +774,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( @@ -955,6 +963,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, @@ -964,6 +973,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( @@ -1074,6 +1084,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, @@ -1083,6 +1094,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 ffc5a5a71e0..07c78d0758c 100644 --- a/litellm/llms/openai/common_utils.py +++ b/litellm/llms/openai/common_utils.py @@ -360,12 +360,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 = ( @@ -376,9 +378,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 a782bf35ac8..6c6e8d27745 100644 --- a/litellm/llms/openai/openai.py +++ b/litellm/llms/openai/openai.py @@ -1734,6 +1734,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: @@ -1774,6 +1775,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( @@ -1782,6 +1784,7 @@ class OpenAIFilesAPI(BaseLLM): timeout=timeout, max_retries=max_retries, organization=organization, + project=project, client=client, _is_async=_is_async, ) @@ -1816,6 +1819,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( @@ -1824,6 +1828,7 @@ class OpenAIFilesAPI(BaseLLM): timeout=timeout, max_retries=max_retries, organization=organization, + project=project, client=client, _is_async=_is_async, ) @@ -1880,6 +1885,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: @@ -1889,6 +1895,7 @@ class OpenAIFilesAPI(BaseLLM): timeout=timeout, max_retries=max_retries, organization=organization, + project=project, client=client, _is_async=_is_async, ) @@ -1944,6 +1951,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( @@ -1952,6 +1960,7 @@ class OpenAIFilesAPI(BaseLLM): timeout=timeout, max_retries=max_retries, organization=organization, + project=project, client=client, _is_async=_is_async, ) @@ -1990,6 +1999,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( @@ -1998,6 +2008,7 @@ class OpenAIFilesAPI(BaseLLM): timeout=timeout, max_retries=max_retries, organization=organization, + project=project, client=client, _is_async=_is_async, ) @@ -2038,6 +2049,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, ): @@ -2047,6 +2059,7 @@ class OpenAIFilesAPI(BaseLLM): timeout=timeout, max_retries=max_retries, organization=organization, + project=project, client=client, _is_async=_is_async, ) @@ -2092,6 +2105,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: @@ -2132,6 +2146,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( @@ -2140,6 +2155,7 @@ class OpenAIBatchesAPI(BaseLLM): timeout=timeout, max_retries=max_retries, organization=organization, + project=project, client=client, _is_async=_is_async, ) @@ -2176,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( @@ -2184,6 +2201,7 @@ class OpenAIBatchesAPI(BaseLLM): timeout=timeout, max_retries=max_retries, organization=organization, + project=project, client=client, _is_async=_is_async, ) @@ -2219,6 +2237,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( @@ -2227,6 +2246,7 @@ class OpenAIBatchesAPI(BaseLLM): timeout=timeout, max_retries=max_retries, organization=organization, + project=project, client=client, _is_async=_is_async, ) @@ -2266,6 +2286,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, @@ -2276,6 +2297,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 104a6452f79..614d56d5cec 100644 --- a/litellm/proxy/batches_endpoints/endpoints.py +++ b/litellm/proxy/batches_endpoints/endpoints.py @@ -41,6 +41,7 @@ from litellm.proxy.common_utils.http_parsing_utils import ( # noqa: F401 # leg 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, ) @@ -284,6 +285,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, @@ -614,6 +616,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: @@ -919,6 +922,8 @@ async def list_batches( proxy_config=proxy_config, route_type="alist_batches", ) + data = dict(data) # rebind-ok: private copy so the project resolution below can drop or set `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") @@ -1113,6 +1118,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 ab44d32f368..182a9b6a528 100644 --- a/litellm/proxy/common_utils/openai_endpoint_utils.py +++ b/litellm/proxy/common_utils/openai_endpoint_utils.py @@ -82,3 +82,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 bdc71908c7c..a701d223993 100644 --- a/litellm/proxy/openai_files_endpoints/files_endpoints.py +++ b/litellm/proxy/openai_files_endpoints/files_endpoints.py @@ -48,6 +48,7 @@ from litellm.proxy.common_utils.http_parsing_utils import ( # noqa: F401 # leg read_request_body, ) 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, @@ -559,6 +560,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), ): @@ -772,6 +774,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, @@ -1011,6 +1017,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 @@ -1326,6 +1333,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 @@ -1532,6 +1540,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( @@ -1740,6 +1749,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 53df3ce6773..ef19d7b8d26 100644 --- a/litellm/types/router.py +++ b/litellm/types/router.py @@ -486,6 +486,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/unit/proxy/batches_endpoints/test_endpoints.py b/tests/unit/proxy/batches_endpoints/test_endpoints.py index 3469dbee748..cc40ec68279 100644 --- a/tests/unit/proxy/batches_endpoints/test_endpoints.py +++ b/tests/unit/proxy/batches_endpoints/test_endpoints.py @@ -3270,3 +3270,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/unit/proxy/common_utils/test_openai_endpoint_utils.py b/tests/unit/proxy/common_utils/test_openai_endpoint_utils.py index 3e3e89f8117..4f80a9f7d39 100644 --- a/tests/unit/proxy/common_utils/test_openai_endpoint_utils.py +++ b/tests/unit/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/unit/proxy/openai_files_endpoint/test_files_endpoint.py b/tests/unit/proxy/openai_files_endpoint/test_files_endpoint.py index 793921255ec..b573907e875 100644 --- a/tests/unit/proxy/openai_files_endpoint/test_files_endpoint.py +++ b/tests/unit/proxy/openai_files_endpoint/test_files_endpoint.py @@ -6130,3 +6130,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 6a34399236c..e6a13a5f734 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -26996,6 +26996,8 @@ export interface components { * @default false */ passthrough: boolean; + /** Project */ + project?: string | null; /** Purpose */ purpose: string; /** @@ -27025,6 +27027,8 @@ export interface components { * @default false */ passthrough: boolean; + /** Project */ + project?: string | null; /** Purpose */ purpose: string; /** @@ -27054,6 +27058,8 @@ export interface components { * @default false */ passthrough: boolean; + /** Project */ + project?: string | null; /** Purpose */ purpose: string; /** @@ -34759,6 +34765,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 */ @@ -49512,6 +49520,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 */