fix(proxy): forward OpenAI project across the full files/batches lifecycle behind an opt-in

Address review: project now resolves once via apply_openai_project_to_data and applies to create/retrieve/list/cancel/get/delete for both batches and files, closing the gap where a file or batch created in a project could not be read back. create_file now accepts the project form field. Client-supplied project is gated by general_settings.forward_openai_project (default drop), mirroring forward_openai_org_id, so it can no longer override the deployment credential. Add resolved_project Final annotation and functional proxy regression tests.
This commit is contained in:
rain 2026-09-28 13:42:29 +08:00
parent 0c15f5c939
commit ccd2ee4846
7 changed files with 380 additions and 5 deletions

View file

@ -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,

View file

@ -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(

View file

@ -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

View file

@ -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

View file

@ -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"

View file

@ -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"}

View file

@ -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)