diff --git a/litellm/llms/openai/common_utils.py b/litellm/llms/openai/common_utils.py index 22270015133..e95931f8d37 100644 --- a/litellm/llms/openai/common_utils.py +++ b/litellm/llms/openai/common_utils.py @@ -371,7 +371,7 @@ 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 = project or os.getenv("OPENAI_PROJECT", 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, diff --git a/litellm/proxy/batches_endpoints/endpoints.py b/litellm/proxy/batches_endpoints/endpoints.py index 3d348a0fdac..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,8 +269,7 @@ async def create_batch( "Request received by LiteLLM:\n%s", json.dumps(data, indent=4), ) - if data.get("project") is None and request.headers.get("OpenAI-Project"): - data["project"] = request.headers.get("OpenAI-Project") + apply_openai_project_to_data(data=data, request=request, general_settings=general_settings) base_llm_response_processor: Final = ProxyBaseLLMRequestProcessing(data=data) ( data, @@ -598,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: @@ -901,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") @@ -1095,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 4c2ffe8ed11..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,8 +760,9 @@ async def create_file( proxy_config=proxy_config, ) - if data.get("project") is None and request.headers.get("OpenAI-Project"): - data["project"] = request.headers.get("OpenAI-Project") + 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, @@ -996,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 @@ -1309,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 @@ -1515,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( @@ -1722,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/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)