From 52ec73c07d52fbf6c2de64200f5d46810377a8f5 Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Tue, 3 Mar 2026 17:29:39 -0800 Subject: [PATCH] fix(proxy): improve team expiry enforcement validation - Change status codes from 400 to 500 for team metadata misconfig errors (callers can't fix admin-set config, 400 is misleading) - Add anchor value validation to batch endpoint (matching files endpoint) - Coerce seconds to int to handle string values from metadata - Add error-path tests: missing keys, invalid anchor, status code assertions - Add happy-path test: team injects expiry when caller sends nothing --- litellm/proxy/batches_endpoints/endpoints.py | 16 ++- .../openai_files_endpoints/files_endpoints.py | 10 +- .../test_files_endpoint.py | 118 ++++++++++++++++++ tests/test_litellm/proxy/test_batch_expiry.py | 100 +++++++++++++++ 4 files changed, 236 insertions(+), 8 deletions(-) diff --git a/litellm/proxy/batches_endpoints/endpoints.py b/litellm/proxy/batches_endpoints/endpoints.py index 850134b649..17855cf9bd 100644 --- a/litellm/proxy/batches_endpoints/endpoints.py +++ b/litellm/proxy/batches_endpoints/endpoints.py @@ -127,12 +127,22 @@ async def create_batch( # noqa: PLR0915 if enforced_batch_expiry is not None: if "anchor" not in enforced_batch_expiry or "seconds" not in enforced_batch_expiry: raise HTTPException( - status_code=400, + status_code=500, detail={ - "error": "enforced_batch_output_expires_after must contain 'anchor' and 'seconds' keys", + "error": "Server configuration error: team metadata field 'enforced_batch_output_expires_after' is malformed - must contain 'anchor' and 'seconds' keys. Contact your team or proxy admin to fix this setting.", }, ) - _create_batch_data["output_expires_after"] = enforced_batch_expiry + if enforced_batch_expiry["anchor"] != "created_at": + raise HTTPException( + status_code=500, + detail={ + "error": f"Server configuration error: team metadata field 'enforced_batch_output_expires_after' has invalid anchor '{enforced_batch_expiry['anchor']}' - must be 'created_at'. Contact your team or proxy admin to fix this setting.", + }, + ) + _create_batch_data["output_expires_after"] = { + "anchor": "created_at", + "seconds": int(enforced_batch_expiry["seconds"]), + } input_file_id = _create_batch_data.get("input_file_id", None) unified_file_id: Union[str, Literal[False]] = False diff --git a/litellm/proxy/openai_files_endpoints/files_endpoints.py b/litellm/proxy/openai_files_endpoints/files_endpoints.py index 44bd9b09d8..8a02f96926 100644 --- a/litellm/proxy/openai_files_endpoints/files_endpoints.py +++ b/litellm/proxy/openai_files_endpoints/files_endpoints.py @@ -460,21 +460,21 @@ async def create_file( # noqa: PLR0915 if enforced_file_expiry is not None: if "anchor" not in enforced_file_expiry or "seconds" not in enforced_file_expiry: raise HTTPException( - status_code=400, + status_code=500, detail={ - "error": "enforced_file_expires_after must contain 'anchor' and 'seconds' keys", + "error": "Server configuration error: team metadata field 'enforced_file_expires_after' is malformed - must contain 'anchor' and 'seconds' keys. Contact your team or proxy admin to fix this setting.", }, ) if enforced_file_expiry["anchor"] != "created_at": raise HTTPException( - status_code=400, + status_code=500, detail={ - "error": f"enforced_file_expires_after anchor must be 'created_at', got '{enforced_file_expiry['anchor']}'", + "error": f"Server configuration error: team metadata field 'enforced_file_expires_after' has invalid anchor '{enforced_file_expiry['anchor']}' - must be 'created_at'. Contact your team or proxy admin to fix this setting.", }, ) expires_after = FileExpiresAfter( anchor="created_at", - seconds=enforced_file_expiry["seconds"], + seconds=int(enforced_file_expiry["seconds"]), ) verbose_proxy_logger.debug( 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 0239c39e67..83f7bb520a 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 @@ -1303,3 +1303,121 @@ def test_file_no_team_setting_preserves_caller( ) assert expires_after["anchor"] == "created_at" assert expires_after["seconds"] == 86400 + + +def test_file_team_injects_when_caller_sends_nothing( + mocker: MockerFixture, monkeypatch, llm_router: Router +): + """Team enforcement applies even when caller sends no expiry.""" + expires_after = _post_file_with_team_metadata( + monkeypatch, + llm_router, + team_metadata={ + "enforced_file_expires_after": { + "anchor": "created_at", + "seconds": 3600, + } + }, + form_data={ + "purpose": "batch", + "target_model_names": "gpt-3.5-turbo", + }, + ) + assert expires_after["anchor"] == "created_at" + assert expires_after["seconds"] == 3600 + + +# --------------------------------------------------------------------------- +# Team-level enforced_file_expires_after validation error tests +# --------------------------------------------------------------------------- + + +def _post_file_raw(monkeypatch, llm_router: Router, team_metadata: dict, form_data: dict): + """POST /v1/files and return the raw response (no status assertion).""" + from litellm.proxy.auth.user_api_key_auth import user_api_key_auth + + proxy_logging_obj = ProxyLogging( + user_api_key_cache=DualCache(default_in_memory_ttl=1) + ) + dummy, _ = _make_capturing_managed_files() + proxy_logging_obj.proxy_hook_mapping["managed_files"] = dummy + monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", llm_router) + monkeypatch.setattr( + "litellm.proxy.proxy_server.proxy_logging_obj", proxy_logging_obj + ) + + user_key = UserAPIKeyAuth(api_key="test-key", team_metadata=team_metadata) + app.dependency_overrides[user_api_key_auth] = lambda: user_key + + test_file = ("mydata.jsonl", b'{"prompt": "Hello"}', "application/json") + try: + response = client.post( + "/v1/files", + files={"file": test_file}, + data=form_data, + headers={"Authorization": "Bearer test-key"}, + ) + finally: + app.dependency_overrides.clear() + + return response + + +def test_file_missing_anchor_key_returns_500( + mocker: MockerFixture, monkeypatch, llm_router: Router +): + """Missing 'anchor' key in team metadata returns 500.""" + response = _post_file_raw( + monkeypatch, + llm_router, + team_metadata={ + "enforced_file_expires_after": {"seconds": 3600}, + }, + form_data={ + "purpose": "batch", + "target_model_names": "gpt-3.5-turbo", + }, + ) + assert response.status_code == 500 + assert "malformed" in response.json()["error"]["message"] + + +def test_file_missing_seconds_key_returns_500( + mocker: MockerFixture, monkeypatch, llm_router: Router +): + """Missing 'seconds' key in team metadata returns 500.""" + response = _post_file_raw( + monkeypatch, + llm_router, + team_metadata={ + "enforced_file_expires_after": {"anchor": "created_at"}, + }, + form_data={ + "purpose": "batch", + "target_model_names": "gpt-3.5-turbo", + }, + ) + assert response.status_code == 500 + assert "malformed" in response.json()["error"]["message"] + + +def test_file_invalid_anchor_returns_500( + mocker: MockerFixture, monkeypatch, llm_router: Router +): + """Invalid anchor value in team metadata returns 500.""" + response = _post_file_raw( + monkeypatch, + llm_router, + team_metadata={ + "enforced_file_expires_after": { + "anchor": "updated_at", + "seconds": 3600, + }, + }, + form_data={ + "purpose": "batch", + "target_model_names": "gpt-3.5-turbo", + }, + ) + assert response.status_code == 500 + assert "created_at" in response.json()["error"]["message"] diff --git a/tests/test_litellm/proxy/test_batch_expiry.py b/tests/test_litellm/proxy/test_batch_expiry.py index 1f54f190c6..d63f278e71 100644 --- a/tests/test_litellm/proxy/test_batch_expiry.py +++ b/tests/test_litellm/proxy/test_batch_expiry.py @@ -160,3 +160,103 @@ class TestBatchEndpointTeamOverride: }, ) assert kwargs["output_expires_after"] == CALLER_EXPIRY + + def test_team_injects_when_caller_sends_nothing(self, monkeypatch, llm_router): + """Team enforcement applies even when caller sends no expiry.""" + kwargs = self._post_batch( + monkeypatch, + llm_router, + team_metadata={ + "enforced_batch_output_expires_after": TEAM_EXPIRY, + }, + request_body={ + "input_file_id": "file-abc123", + "endpoint": "/v1/chat/completions", + "completion_window": "24h", + }, + ) + assert kwargs["output_expires_after"] == TEAM_EXPIRY + + +class TestBatchEndpointTeamValidation: + """Verify validation errors for malformed team metadata on batch endpoint.""" + + def _post_batch_raw( + self, + monkeypatch, + llm_router: Router, + team_metadata: dict, + request_body: dict, + ): + """POST /v1/batches and return the raw response (no status assertion).""" + _setup_proxy(monkeypatch, llm_router) + + user_key = UserAPIKeyAuth( + api_key="test-key", + team_metadata=team_metadata, + ) + app.dependency_overrides[user_api_key_auth] = lambda: user_key + + async def mock_acreate_batch(**kwargs): + return _make_batch_response() + + monkeypatch.setattr(litellm, "acreate_batch", mock_acreate_batch) + + try: + response = client.post( + "/v1/batches", + json=request_body, + headers={"Authorization": "Bearer test-key"}, + ) + finally: + app.dependency_overrides.clear() + + return response + + _BATCH_BODY = { + "input_file_id": "file-abc123", + "endpoint": "/v1/chat/completions", + "completion_window": "24h", + } + + def test_missing_anchor_key_returns_500(self, monkeypatch, llm_router): + """Missing 'anchor' key in team metadata returns 500.""" + response = self._post_batch_raw( + monkeypatch, + llm_router, + team_metadata={ + "enforced_batch_output_expires_after": {"seconds": 3600}, + }, + request_body=self._BATCH_BODY, + ) + assert response.status_code == 500 + assert "malformed" in response.json()["error"]["message"] + + def test_missing_seconds_key_returns_500(self, monkeypatch, llm_router): + """Missing 'seconds' key in team metadata returns 500.""" + response = self._post_batch_raw( + monkeypatch, + llm_router, + team_metadata={ + "enforced_batch_output_expires_after": {"anchor": "created_at"}, + }, + request_body=self._BATCH_BODY, + ) + assert response.status_code == 500 + assert "malformed" in response.json()["error"]["message"] + + def test_invalid_anchor_returns_500(self, monkeypatch, llm_router): + """Invalid anchor value in team metadata returns 500.""" + response = self._post_batch_raw( + monkeypatch, + llm_router, + team_metadata={ + "enforced_batch_output_expires_after": { + "anchor": "last_active_at", + "seconds": 3600, + }, + }, + request_body=self._BATCH_BODY, + ) + assert response.status_code == 500 + assert "created_at" in response.json()["error"]["message"]