From 2c00311599bb4c638f11aa1ecba1c816a131d597 Mon Sep 17 00:00:00 2001 From: Harshit28j Date: Thu, 26 Feb 2026 22:17:30 +0530 Subject: [PATCH] fix: tags on projects --- litellm/proxy/_types.py | 13 ++++++++-- litellm/proxy/auth/user_api_key_auth.py | 8 ++++++ litellm/proxy/litellm_pre_call_utils.py | 9 +++++++ .../management_endpoints/project_endpoints.py | 26 ++++++++++++++++--- 4 files changed, 51 insertions(+), 5 deletions(-) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index de1609baf62..0e38e8ad8f4 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -2257,6 +2257,7 @@ class LiteLLM_VerificationTokenView(LiteLLM_VerificationToken): team_member: Optional[Member] = None team_metadata: Optional[Dict] = None team_object_permission_id: Optional[str] = None + project_metadata: Optional[Dict] = None # Team Member Specific Params team_member_spend: Optional[float] = None @@ -2581,11 +2582,15 @@ class NewProjectRequest(LiteLLM_BudgetTable): model_tpm_limit: Optional[dict] = None blocked: bool = False object_permission: Optional[LiteLLM_ObjectPermissionBase] = None + tags: Optional[List[str]] = None @model_validator(mode="before") @classmethod def set_model_info(cls, values): - for field in LiteLLM_ManagementEndpoint_MetadataFields: + 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": {}}) @@ -2608,11 +2613,15 @@ class UpdateProjectRequest(LiteLLM_BudgetTable): blocked: Optional[bool] = None budget_id: Optional[str] = None object_permission: Optional[LiteLLM_ObjectPermissionBase] = None + tags: Optional[List[str]] = None @model_validator(mode="before") @classmethod def set_model_info(cls, values): - for field in LiteLLM_ManagementEndpoint_MetadataFields: + 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 133f8ec136d..8c31da1241c 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -692,6 +692,10 @@ async def _user_api_key_auth_builder( # noqa: PLR0915 project_object=_jwt_project_obj, ) + # Add project metadata for tag-based routing when key belongs to a project + if _jwt_project_obj is not None: + valid_token.project_metadata = _jwt_project_obj.metadata + # return UserAPIKeyAuth object return cast(UserAPIKeyAuth, valid_token) @@ -1311,6 +1315,10 @@ async def _user_api_key_auth_builder( # noqa: PLR0915 valid_token_dict = valid_token.model_dump(exclude_none=True) valid_token_dict.pop("token", None) + # Add project metadata when key belongs to a project (for tag-based routing) + if _project_obj is not None: + valid_token_dict["project_metadata"] = _project_obj.metadata + if _end_user_object is not None: valid_token_dict.update(end_user_params) valid_token_dict[ diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index d2312a00c3b..1cb456dcbd3 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -990,6 +990,15 @@ async def add_litellm_data_to_request( # noqa: PLR0915 data=data, _metadata_variable_name=_metadata_variable_name, ) + ## PROJECT-LEVEL TAGS (project sits between key and team in hierarchy) + project_metadata = getattr( + user_api_key_dict, "project_metadata", None + ) 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 SPEND LOGS/TAGS team_metadata = user_api_key_dict.team_metadata or {} if "tags" in team_metadata and team_metadata["tags"] is not None: diff --git a/litellm/proxy/management_endpoints/project_endpoints.py b/litellm/proxy/management_endpoints/project_endpoints.py index ba3238ebfd5..392c905f828 100644 --- a/litellm/proxy/management_endpoints/project_endpoints.py +++ b/litellm/proxy/management_endpoints/project_endpoints.py @@ -17,7 +17,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request from litellm._logging import verbose_proxy_logger from litellm._uuid import uuid -from litellm.proxy._types import * +from litellm.proxy._types import * # noqa: F403 from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.management_endpoints.common_utils import _set_object_metadata_field from litellm.proxy.management_helpers.utils import ( @@ -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[str]]* - Tags for tracking spend and/or tag-based routing. Keys belonging to this project inherit these tags. - 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. @@ -421,6 +422,13 @@ async def new_project( updated_by=user_api_key_dict.user_id or litellm_proxy_admin_name, ) + for field in LiteLLM_ManagementEndpoint_MetadataFields_Premium: + if getattr(data, field, None) is not None: + _set_object_metadata_field( + object_data=project_row, + field_name=field, + value=getattr(data, field), + ) for field in LiteLLM_ManagementEndpoint_MetadataFields: if getattr(data, field, None) is not None: _set_object_metadata_field( @@ -478,6 +486,7 @@ async def update_project( - team_id: *Optional[str]* - Updated team_id for the project - metadata: *Optional[dict]* - Updated metadata for project - models: *Optional[list]* - Updated list of models for the project + - tags: *Optional[List[str]]* - Tags for tracking spend and/or tag-based routing - blocked: *Optional[bool]* - Updated blocked status - max_budget: *Optional[float]* - Updated max budget - tpm_limit: *Optional[int]* - Updated tpm limit @@ -626,13 +635,24 @@ async def update_project( "object_permission_id" ] = created_permission.object_permission_id - # Handle metadata fields - for field in LiteLLM_ManagementEndpoint_MetadataFields: + # Handle metadata fields (Premium + standard) + for field in ( + LiteLLM_ManagementEndpoint_MetadataFields_Premium + + LiteLLM_ManagementEndpoint_MetadataFields + ): if field in update_data: if update_data.get("metadata") is None: update_data["metadata"] = {} update_data["metadata"][field] = update_data.pop(field) + # Merge metadata with existing (avoid wiping metadata on partial updates like tags) + if "metadata" in update_data: + existing_metadata = (existing_project.metadata or {}) if isinstance( + existing_project.metadata, dict + ) else {} + new_metadata = update_data.get("metadata") or {} + update_data["metadata"] = {**existing_metadata, **new_metadata} + # Remove budget fields (following organization_endpoints.py pattern) update_data = _remove_budget_fields_from_project_data(update_data)