feat: add tags in project

This commit is contained in:
Harshit28j 2026-02-27 01:07:18 +05:30 committed by Sameer Kankute
parent ec90e1b8c3
commit 3db85ca017
5 changed files with 108 additions and 10 deletions

View file

@ -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": {}})

View file

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

View file

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

View file

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

View file

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