From d3d11fb06ec624b4abc6f3ae2d2e950cc59a6eac Mon Sep 17 00:00:00 2001 From: Harshit28j Date: Fri, 27 Feb 2026 01:07:18 +0530 Subject: [PATCH] feat: add tags in project --- litellm/proxy/_types.py | 25 ++++++++++++- litellm/proxy/auth/user_api_key_auth.py | 12 +++++- litellm/proxy/litellm_pre_call_utils.py | 24 +++++++++--- .../management_endpoints/project_endpoints.py | 20 ++++++++++ .../test_project_tags_pydantic.py | 37 +++++++++++++++++++ 5 files changed, 108 insertions(+), 10 deletions(-) create mode 100644 tests/test_litellm/test_project_tags_pydantic.py diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 53513f7f522..8713f54a3ef 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -2280,6 +2280,9 @@ class LiteLLM_VerificationTokenView(LiteLLM_VerificationToken): organization_rpm_limit: Optional[int] = None organization_metadata: Optional[dict] = None + # Project Params + project_metadata: Optional[dict] = None + # Time stamps last_refreshed_at: Optional[float] = None # last time joint view was pulled from db @@ -2581,6 +2584,7 @@ class NewProjectRequest(LiteLLM_BudgetTable): team_id: str budget_id: Optional[str] = None metadata: Optional[dict] = None + tags: Optional[List[str]] = None models: List[str] = [] model_rpm_limit: Optional[dict] = None model_tpm_limit: Optional[dict] = None @@ -2590,7 +2594,15 @@ class NewProjectRequest(LiteLLM_BudgetTable): @model_validator(mode="before") @classmethod def set_model_info(cls, values): - for field in LiteLLM_ManagementEndpoint_MetadataFields: + if "tags" in values and values["tags"] is not None: + if not isinstance(values["tags"], list): + raise ValueError( + f"tags must be a list of strings, got {type(values['tags']).__name__}" + ) + for field in ( + LiteLLM_ManagementEndpoint_MetadataFields + + LiteLLM_ManagementEndpoint_MetadataFields_Premium + ): if values.get(field) is not None: if values.get("metadata") is None: values.update({"metadata": {}}) @@ -2607,6 +2619,7 @@ class UpdateProjectRequest(LiteLLM_BudgetTable): description: Optional[str] = None team_id: Optional[str] = None metadata: Optional[dict] = None + tags: Optional[List[str]] = None models: Optional[List[str]] = None model_rpm_limit: Optional[dict] = None model_tpm_limit: Optional[dict] = None @@ -2617,7 +2630,15 @@ class UpdateProjectRequest(LiteLLM_BudgetTable): @model_validator(mode="before") @classmethod def set_model_info(cls, values): - for field in LiteLLM_ManagementEndpoint_MetadataFields: + if "tags" in values and values["tags"] is not None: + if not isinstance(values["tags"], list): + raise ValueError( + f"tags must be a list of strings, got {type(values['tags']).__name__}" + ) + for field in ( + LiteLLM_ManagementEndpoint_MetadataFields + + LiteLLM_ManagementEndpoint_MetadataFields_Premium + ): if values.get(field) is not None: if values.get("metadata") is None: values.update({"metadata": {}}) diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index 3e2378ada60..8ad3b83c043 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -212,10 +212,12 @@ async def user_api_key_auth_websocket(websocket: WebSocket): api_key = websocket.headers.get("api-key") if not api_key: # Try extracting from WebSocket subprotocol (browser clients) - for protocol in websocket.headers.get("sec-websocket-protocol", "").split(","): + for protocol in websocket.headers.get("sec-websocket-protocol", "").split( + "," + ): protocol = protocol.strip() if protocol.startswith("openai-insecure-api-key."): - api_key = protocol[len("openai-insecure-api-key."):] + api_key = protocol[len("openai-insecure-api-key.") :] break if not api_key: await websocket.close(code=status.WS_1008_POLICY_VIOLATION) @@ -704,6 +706,8 @@ async def _user_api_key_auth_builder( # noqa: PLR0915 user_api_key_cache=user_api_key_cache, proxy_logging_obj=proxy_logging_obj, ) + if _jwt_project_obj is not None: + valid_token.project_metadata = _jwt_project_obj.metadata # run through common checks _ = await common_checks( @@ -1294,6 +1298,8 @@ async def _user_api_key_auth_builder( # noqa: PLR0915 user_api_key_cache=user_api_key_cache, proxy_logging_obj=proxy_logging_obj, ) + if _project_obj is not None: + valid_token.project_metadata = _project_obj.metadata global_proxy_spend = None if ( @@ -1743,6 +1749,8 @@ async def _run_post_custom_auth_checks( user_api_key_cache=user_api_key_cache, proxy_logging_obj=proxy_logging_obj, ) + if _project_obj is not None: + valid_token.project_metadata = _project_obj.metadata _ = await common_checks( request=request, diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index 7ebf9a4caee..eac1c4a33c6 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -248,13 +248,15 @@ def clean_headers( clean_headers = {} litellm_key_lower = ( litellm_key_header_name.lower() if litellm_key_header_name is not None else None - ) + ) for header, value in headers.items(): header_lower = header.lower() - + if header_lower == "authorization" and is_anthropic_oauth_key(value): clean_headers[header] = value - elif forward_llm_provider_auth_headers and header_lower in _SPECIAL_HEADERS_CACHE: + elif ( + forward_llm_provider_auth_headers and header_lower in _SPECIAL_HEADERS_CACHE + ): if litellm_key_lower and header_lower == litellm_key_lower: continue if header_lower == "authorization": @@ -840,11 +842,13 @@ async def add_litellm_data_to_request( # noqa: PLR0915 from litellm.types.proxy.litellm_pre_call_utils import SecretFields _raw_headers: Dict[str, str] = _safe_get_request_headers(request) - + forward_llm_auth = False if general_settings: - forward_llm_auth = general_settings.get("forward_llm_provider_auth_headers", False) - + forward_llm_auth = general_settings.get( + "forward_llm_provider_auth_headers", False + ) + _headers: Dict[str, str] = clean_headers( request.headers, litellm_key_header_name=( @@ -1019,6 +1023,14 @@ async def add_litellm_data_to_request( # noqa: PLR0915 "spend_logs_metadata" ] + ## PROJECT-LEVEL SPEND LOGS/TAGS + project_metadata = user_api_key_dict.project_metadata or {} + if "tags" in project_metadata and project_metadata["tags"] is not None: + data[_metadata_variable_name]["tags"] = LiteLLMProxyRequestSetup._merge_tags( + request_tags=data[_metadata_variable_name].get("tags"), + tags_to_add=project_metadata["tags"], + ) + ## TEAM-LEVEL METADATA data = ( LiteLLMProxyRequestSetup.add_management_endpoint_metadata_to_request_metadata( diff --git a/litellm/proxy/management_endpoints/project_endpoints.py b/litellm/proxy/management_endpoints/project_endpoints.py index ba3238ebfd5..c825802f314 100644 --- a/litellm/proxy/management_endpoints/project_endpoints.py +++ b/litellm/proxy/management_endpoints/project_endpoints.py @@ -284,6 +284,7 @@ async def new_project( - model_tpm_limit: *Optional[dict]* - TPM limits per model. Example: {"gpt-4": 50000, "gpt-3.5-turbo": 100000} - budget_duration: *Optional[str]* - Frequency of reseting project budget - metadata: *Optional[dict]* - Metadata for project, store information for project. Example metadata - {"use_case_id": "SNOW-12345", "responsible_ai_id": "RAI-67890"} + - tags: *Optional[list]* - Tags for the project. Example: ["production", "api"] - blocked: *bool* - Flag indicating if the project is blocked or not - will stop all calls from keys with this project_id. - object_permission: Optional[LiteLLM_ObjectPermissionBase] - project-specific object permission. Example - {"vector_stores": ["vector_store_1", "vector_store_2"]}. IF null or {} then no object permission. @@ -339,6 +340,15 @@ async def new_project( ) try: + if getattr(data, "tags", None) is not None and not premium_user: + raise HTTPException( + status_code=403, + detail={ + "error": "Only premium users can add tags to projects. " + + CommonProxyErrors.not_premium_user.value + }, + ) + if not premium_user: raise HTTPException( status_code=403, @@ -485,6 +495,7 @@ async def update_project( - model_rpm_limit: *Optional[dict]* - Updated RPM limits per model - model_tpm_limit: *Optional[dict]* - Updated TPM limits per model - budget_duration: *Optional[str]* - Updated budget duration + - tags: *Optional[list]* - Updated list of tags for the project - object_permission: Optional[LiteLLM_ObjectPermissionBase] - Updated object permission Example: @@ -514,6 +525,15 @@ async def update_project( ) try: + if getattr(data, "tags", None) is not None and not premium_user: + raise HTTPException( + status_code=403, + detail={ + "error": "Only premium users can add tags to projects. " + + CommonProxyErrors.not_premium_user.value + }, + ) + if not premium_user: raise HTTPException( status_code=403, diff --git a/tests/test_litellm/test_project_tags_pydantic.py b/tests/test_litellm/test_project_tags_pydantic.py new file mode 100644 index 00000000000..ed3b29fe7be --- /dev/null +++ b/tests/test_litellm/test_project_tags_pydantic.py @@ -0,0 +1,37 @@ +import pytest +from litellm.proxy._types import NewProjectRequest, UpdateProjectRequest + + +def test_new_project_request_tags(): + # Test tags are correctly moved to metadata["tags"] + req = NewProjectRequest( + project_id="test_proj", team_id="team_1", tags=["tag1", "tag2"] + ) + + # After validation, tags should be inside metadata + assert req.metadata is not None + assert "tags" in req.metadata + assert req.metadata["tags"] == ["tag1", "tag2"] + assert req.tags is None # Or removed dependending on pydantic version + + +def test_update_project_request_tags(): + # Test tags are correctly moved to metadata["tags"] + req = UpdateProjectRequest(project_id="test_proj", tags=["new_tag"]) + + assert req.metadata is not None + assert "tags" in req.metadata + assert req.metadata["tags"] == ["new_tag"] + assert req.tags is None + + +def test_new_project_request_invalid_tags_type(): + # tags must be a list — a string should raise a ValidationError + with pytest.raises(Exception): + NewProjectRequest(project_id="test_proj", team_id="team_1", tags="not-a-list") + + +def test_update_project_request_invalid_tags_type(): + # tags must be a list — a string should raise a ValidationError + with pytest.raises(Exception): + UpdateProjectRequest(project_id="test_proj", tags="not-a-list")