Skip to content

Commit 00e17c8

Browse files
Add enforce user param functionality (#17088)
* feat: Add reject_metadata_tags to proxy config Co-authored-by: krrishdholakia <[email protected]> * Refactor: Rename reject_metadata_tags to reject_clientside_metadata_tags Co-authored-by: krrishdholakia <[email protected]> --------- Co-authored-by: Cursor Agent <[email protected]>
1 parent 6dcb542 commit 00e17c8

File tree

6 files changed

+337
-0
lines changed

6 files changed

+337
-0
lines changed

docs/my-website/docs/proxy/config_settings.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ general_settings:
104104
disable_responses_id_security: boolean # turn off response ID security checks that prevent users from accessing other users' responses
105105
enable_jwt_auth: boolean # allow proxy admin to auth in via jwt tokens with 'litellm_proxy_admin' in claims
106106
enforce_user_param: boolean # requires all openai endpoint requests to have a 'user' param
107+
reject_clientside_metadata_tags: boolean # if true, rejects requests with client-side 'metadata.tags' to prevent users from influencing budgets
107108
allowed_routes: ["route1", "route2"] # list of allowed proxy API routes - a user can access. (currently JWT-Auth only)
108109
key_management_system: google_kms # either google_kms or azure_kms
109110
master_key: string
@@ -201,6 +202,7 @@ router_settings:
201202
| disable_responses_id_security | boolean | If true, disables response ID security checks that prevent users from accessing response IDs from other users. When false (default), response IDs are encrypted with user information to ensure users can only access their own responses. Applies to /v1/responses endpoints |
202203
| enable_jwt_auth | boolean | allow proxy admin to auth in via jwt tokens with 'litellm_proxy_admin' in claims. [Doc on JWT Tokens](token_auth) |
203204
| enforce_user_param | boolean | If true, requires all OpenAI endpoint requests to have a 'user' param. [Doc on call hooks](call_hooks)|
205+
| reject_clientside_metadata_tags | boolean | If true, rejects requests that contain client-side 'metadata.tags' to prevent users from influencing budgets by sending different tags. Tags can only be inherited from the API key metadata. |
204206
| allowed_routes | array of strings | List of allowed proxy API routes a user can access [Doc on controlling allowed routes](enterprise#control-available-public-private-routes)|
205207
| key_management_system | string | Specifies the key management system. [Doc Secret Managers](../secret) |
206208
| master_key | string | The master key for the proxy [Set up Virtual Keys](virtual_keys) |
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
# Reject Client-Side Metadata Tags
2+
3+
## Overview
4+
5+
The `reject_clientside_metadata_tags` setting allows you to prevent users from passing client-side `metadata.tags` in their API requests. This ensures that tags are only inherited from the API key metadata and cannot be overridden by users to potentially influence budget tracking or routing decisions.
6+
7+
## Use Case
8+
9+
This feature is particularly useful in multi-tenant scenarios where:
10+
- You want to enforce strict budget tracking based on API key tags
11+
- You want to prevent users from manipulating routing decisions by sending custom client-side tags
12+
- You need to ensure consistent tag-based filtering and reporting
13+
14+
## Configuration
15+
16+
Add the following to your `config.yaml`:
17+
18+
```yaml
19+
general_settings:
20+
reject_clientside_metadata_tags: true # Default is false/null
21+
```
22+
23+
## Behavior
24+
25+
### When `reject_clientside_metadata_tags: true`
26+
27+
**Rejected Request Example:**
28+
```bash
29+
curl -X POST http://localhost:4000/chat/completions \
30+
-H "Authorization: Bearer sk-1234" \
31+
-H "Content-Type: application/json" \
32+
-d '{
33+
"model": "gpt-3.5-turbo",
34+
"messages": [{"role": "user", "content": "Hello"}],
35+
"metadata": {
36+
"tags": ["custom-tag"] # This will be rejected
37+
}
38+
}'
39+
```
40+
41+
**Error Response:**
42+
```json
43+
{
44+
"error": {
45+
"message": "Client-side 'metadata.tags' not allowed in request. 'reject_clientside_metadata_tags'=True. Tags can only be set via API key metadata.",
46+
"type": "bad_request_error",
47+
"param": "metadata.tags",
48+
"code": 400
49+
}
50+
}
51+
```
52+
53+
**Allowed Request Example:**
54+
```bash
55+
curl -X POST http://localhost:4000/chat/completions \
56+
-H "Authorization: Bearer sk-1234" \
57+
-H "Content-Type: application/json" \
58+
-d '{
59+
"model": "gpt-3.5-turbo",
60+
"messages": [{"role": "user", "content": "Hello"}],
61+
"metadata": {
62+
"custom_field": "value" # Other metadata fields are allowed
63+
}
64+
}'
65+
```
66+
67+
### When `reject_clientside_metadata_tags: false` or not set
68+
69+
All requests are allowed, including those with client-side `metadata.tags`.
70+
71+
## Setting Tags via API Key
72+
73+
When `reject_clientside_metadata_tags` is enabled, tags should be set on the API key metadata:
74+
75+
```bash
76+
curl -X POST http://localhost:4000/key/generate \
77+
-H "Authorization: Bearer sk-master-key" \
78+
-H "Content-Type: application/json" \
79+
-d '{
80+
"metadata": {
81+
"tags": ["team-a", "production"]
82+
}
83+
}'
84+
```
85+
86+
These tags will be automatically inherited by all requests made with that API key.
87+
88+
## Complete Example Configuration
89+
90+
```yaml
91+
model_list:
92+
- model_name: gpt-3.5-turbo
93+
litellm_params:
94+
model: gpt-3.5-turbo
95+
api_key: os.environ/OPENAI_API_KEY
96+
97+
general_settings:
98+
master_key: sk-1234
99+
database_url: "postgresql://user:password@localhost:5432/litellm"
100+
101+
# Reject client-side tags
102+
reject_clientside_metadata_tags: true
103+
104+
# Optional: Also enforce user parameter
105+
enforce_user_param: true
106+
```
107+
108+
## Similar Features
109+
110+
- `enforce_user_param` - Requires all requests to include a 'user' parameter
111+
- Tag-based routing - Use tags for intelligent request routing
112+
- Budget tracking - Track spending per tag
113+
114+
## Notes
115+
116+
- This check only applies to LLM API routes (e.g., `/chat/completions`, `/embeddings`)
117+
- Management endpoints (e.g., `/key/generate`) are not affected
118+
- The check validates that client-side `metadata.tags` is not present in the request body
119+
- Other metadata fields can still be passed in requests
120+
- Tags set on API keys will still be applied to all requests

litellm/proxy/_types.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1889,6 +1889,10 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase):
18891889
allowed_routes: Optional[List] = Field(
18901890
None, description="Proxy API Endpoints you want users to be able to access"
18911891
)
1892+
reject_clientside_metadata_tags: Optional[bool] = Field(
1893+
None,
1894+
description="When set to True, rejects requests that contain client-side 'metadata.tags' to prevent users from influencing budgets by sending different tags. Tags can only be inherited from the API key metadata.",
1895+
)
18921896
enable_public_model_hub: bool = Field(
18931897
default=False,
18941898
description="Public model hub for users to see what models they have access to, supported openai params, etc.",

litellm/proxy/auth/auth_checks.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,24 @@ async def common_checks(
186186
raise Exception(
187187
f"'user' param not passed in. 'enforce_user_param'={general_settings['enforce_user_param']}"
188188
)
189+
190+
# 6.1 [OPTIONAL] If 'reject_clientside_metadata_tags' enabled - reject request if it has client-side 'metadata.tags'
191+
if (
192+
general_settings.get("reject_clientside_metadata_tags", None) is not None
193+
and general_settings["reject_clientside_metadata_tags"] is True
194+
):
195+
if (
196+
RouteChecks.is_llm_api_route(route=route)
197+
and "metadata" in request_body
198+
and isinstance(request_body["metadata"], dict)
199+
and "tags" in request_body["metadata"]
200+
):
201+
raise ProxyException(
202+
message=f"Client-side 'metadata.tags' not allowed in request. 'reject_clientside_metadata_tags'={general_settings['reject_clientside_metadata_tags']}. Tags can only be set via API key metadata.",
203+
type=ProxyErrorTypes.bad_request_error,
204+
param="metadata.tags",
205+
code=status.HTTP_400_BAD_REQUEST,
206+
)
189207
# 7. [OPTIONAL] If 'litellm.max_budget' is set (>0), is proxy under budget
190208
if (
191209
litellm.max_budget > 0
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
model_list:
2+
- model_name: gpt-3.5-turbo
3+
litellm_params:
4+
model: gpt-3.5-turbo
5+
api_key: os.environ/OPENAI_API_KEY
6+
7+
general_settings:
8+
master_key: sk-1234
9+
database_url: "postgresql://user:password@localhost:5432/litellm"
10+
11+
# Reject requests that contain client-side metadata.tags
12+
# This prevents users from influencing budgets by sending different tags
13+
# Tags can only be inherited from the API key metadata
14+
reject_clientside_metadata_tags: true

tests/test_litellm/proxy/auth/test_auth_checks.py

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -732,3 +732,182 @@ async def test_get_team_object_raises_404_when_not_found():
732732

733733
assert exc_info.value.status_code == 404
734734
assert "Team doesn't exist in db" in str(exc_info.value.detail)
735+
736+
737+
# Reject Client-Side Metadata Tags Tests
738+
739+
740+
@pytest.mark.asyncio
741+
async def test_reject_clientside_metadata_tags_enabled_with_tags():
742+
"""Test that common_checks rejects request when reject_clientside_metadata_tags is True and metadata.tags is present"""
743+
from litellm.proxy.auth.auth_checks import common_checks
744+
from fastapi import Request
745+
746+
request_body = {
747+
"model": "gpt-3.5-turbo",
748+
"messages": [{"role": "user", "content": "test"}],
749+
"metadata": {"tags": ["custom-tag"]},
750+
}
751+
752+
general_settings = {"reject_clientside_metadata_tags": True}
753+
754+
# Create a mock request object
755+
mock_request = MagicMock(spec=Request)
756+
757+
with pytest.raises(ProxyException) as exc_info:
758+
await common_checks(
759+
request_body=request_body,
760+
team_object=None,
761+
user_object=None,
762+
end_user_object=None,
763+
global_proxy_spend=None,
764+
general_settings=general_settings,
765+
route="/chat/completions",
766+
llm_router=None,
767+
proxy_logging_obj=MagicMock(),
768+
valid_token=None,
769+
request=mock_request,
770+
)
771+
772+
assert exc_info.value.type == ProxyErrorTypes.bad_request_error
773+
assert "metadata.tags" in exc_info.value.message
774+
assert exc_info.value.param == "metadata.tags"
775+
assert exc_info.value.code == 400
776+
777+
778+
@pytest.mark.asyncio
779+
async def test_reject_clientside_metadata_tags_enabled_without_tags():
780+
"""Test that common_checks allows request when reject_clientside_metadata_tags is True but no metadata.tags is present"""
781+
from litellm.proxy.auth.auth_checks import common_checks
782+
from fastapi import Request
783+
784+
request_body = {
785+
"model": "gpt-3.5-turbo",
786+
"messages": [{"role": "user", "content": "test"}],
787+
"metadata": {"custom_field": "value"}, # No tags field
788+
}
789+
790+
general_settings = {"reject_clientside_metadata_tags": True}
791+
792+
# Create a mock request object
793+
mock_request = MagicMock(spec=Request)
794+
795+
# Should not raise an exception
796+
result = await common_checks(
797+
request_body=request_body,
798+
team_object=None,
799+
user_object=None,
800+
end_user_object=None,
801+
global_proxy_spend=None,
802+
general_settings=general_settings,
803+
route="/chat/completions",
804+
llm_router=None,
805+
proxy_logging_obj=MagicMock(),
806+
valid_token=None,
807+
request=mock_request,
808+
)
809+
810+
assert result is True
811+
812+
813+
@pytest.mark.asyncio
814+
async def test_reject_clientside_metadata_tags_disabled_with_tags():
815+
"""Test that common_checks allows request with metadata.tags when reject_clientside_metadata_tags is False"""
816+
from litellm.proxy.auth.auth_checks import common_checks
817+
from fastapi import Request
818+
819+
request_body = {
820+
"model": "gpt-3.5-turbo",
821+
"messages": [{"role": "user", "content": "test"}],
822+
"metadata": {"tags": ["custom-tag"]},
823+
}
824+
825+
general_settings = {"reject_clientside_metadata_tags": False}
826+
827+
# Create a mock request object
828+
mock_request = MagicMock(spec=Request)
829+
830+
# Should not raise an exception
831+
result = await common_checks(
832+
request_body=request_body,
833+
team_object=None,
834+
user_object=None,
835+
end_user_object=None,
836+
global_proxy_spend=None,
837+
general_settings=general_settings,
838+
route="/chat/completions",
839+
llm_router=None,
840+
proxy_logging_obj=MagicMock(),
841+
valid_token=None,
842+
request=mock_request,
843+
)
844+
845+
assert result is True
846+
847+
848+
@pytest.mark.asyncio
849+
async def test_reject_clientside_metadata_tags_not_set_with_tags():
850+
"""Test that common_checks allows request with metadata.tags when reject_clientside_metadata_tags is not set"""
851+
from litellm.proxy.auth.auth_checks import common_checks
852+
from fastapi import Request
853+
854+
request_body = {
855+
"model": "gpt-3.5-turbo",
856+
"messages": [{"role": "user", "content": "test"}],
857+
"metadata": {"tags": ["custom-tag"]},
858+
}
859+
860+
general_settings = {} # No reject_clientside_metadata_tags setting
861+
862+
# Create a mock request object
863+
mock_request = MagicMock(spec=Request)
864+
865+
# Should not raise an exception
866+
result = await common_checks(
867+
request_body=request_body,
868+
team_object=None,
869+
user_object=None,
870+
end_user_object=None,
871+
global_proxy_spend=None,
872+
general_settings=general_settings,
873+
route="/chat/completions",
874+
llm_router=None,
875+
proxy_logging_obj=MagicMock(),
876+
valid_token=None,
877+
request=mock_request,
878+
)
879+
880+
assert result is True
881+
882+
883+
@pytest.mark.asyncio
884+
async def test_reject_clientside_metadata_tags_non_llm_route():
885+
"""Test that reject_clientside_metadata_tags check only applies to LLM API routes"""
886+
from litellm.proxy.auth.auth_checks import common_checks
887+
from fastapi import Request
888+
889+
request_body = {
890+
"metadata": {"tags": ["custom-tag"]},
891+
}
892+
893+
general_settings = {"reject_clientside_metadata_tags": True}
894+
895+
# Create a mock request object
896+
mock_request = MagicMock(spec=Request)
897+
898+
# Should not raise an exception for non-LLM route
899+
result = await common_checks(
900+
request_body=request_body,
901+
team_object=None,
902+
user_object=None,
903+
end_user_object=None,
904+
global_proxy_spend=None,
905+
general_settings=general_settings,
906+
route="/key/generate", # Management route, not LLM route
907+
llm_router=None,
908+
proxy_logging_obj=MagicMock(),
909+
valid_token=None,
910+
request=mock_request,
911+
)
912+
913+
assert result is True

0 commit comments

Comments
 (0)