Merge pull request #24055 from BerriAI/litellm_yj_march_17_2026

[Infra] Merge daily branch with main
This commit is contained in:
yuneng-jiang
2026-03-18 15:49:43 -07:00
committed by GitHub
26 changed files with 1189 additions and 148 deletions
Binary file not shown.
@@ -0,0 +1,9 @@
-- CreateIndex
CREATE INDEX "LiteLLM_TeamTable_organization_id_idx" ON "LiteLLM_TeamTable"("organization_id");
-- CreateIndex
CREATE INDEX "LiteLLM_TeamTable_team_alias_idx" ON "LiteLLM_TeamTable"("team_alias");
-- CreateIndex
CREATE INDEX "LiteLLM_TeamTable_created_at_idx" ON "LiteLLM_TeamTable"("created_at");
@@ -146,6 +146,10 @@ model LiteLLM_TeamTable {
litellm_model_table LiteLLM_ModelTable? @relation(fields: [model_id], references: [id])
object_permission LiteLLM_ObjectPermissionTable? @relation(fields: [object_permission_id], references: [object_permission_id])
projects LiteLLM_ProjectTable[]
@@index([organization_id])
@@index([team_alias])
@@index([created_at])
}
// Projects sit between teams and keys for use-case management
+2 -2
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "litellm-proxy-extras"
version = "0.4.57"
version = "0.4.58"
description = "Additional files for the LiteLLM Proxy. Reduces the size of the main litellm package."
authors = ["BerriAI"]
readme = "README.md"
@@ -22,7 +22,7 @@ requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
[tool.commitizen]
version = "0.4.57"
version = "0.4.58"
version_files = [
"pyproject.toml:version",
"../requirements.txt:litellm-proxy-extras==",
@@ -101,6 +101,7 @@ from litellm.types.proxy.management_endpoints.team_endpoints import (
BulkTeamMemberAddRequest,
BulkTeamMemberAddResponse,
GetTeamMemberPermissionsResponse,
TeamListItem,
TeamListResponse,
TeamMemberAddResult,
UpdateTeamMemberPermissionsRequest,
@@ -3212,6 +3213,40 @@ async def list_available_teams(
return available_teams_correct_type
async def _get_org_admin_org_ids(
user_id: str,
prisma_client: Any,
user_api_key_cache: Any,
proxy_logging_obj: Any,
) -> Optional[List[str]]:
"""
Return the list of organization IDs where the user is an org admin.
Returns None if the user is not an org admin of any organization or if
the user cannot be found.
"""
try:
caller_user = await get_user_object(
user_id=user_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
user_id_upsert=False,
proxy_logging_obj=proxy_logging_obj,
)
except ValueError:
# get_user_object raises ValueError when the user doesn't exist
return None
if caller_user is None:
return None
org_ids = [
m.organization_id
for m in (caller_user.organization_memberships or [])
if m.user_role == LitellmUserRoles.ORG_ADMIN.value
]
return org_ids if org_ids else None
async def _build_team_list_where_conditions(
prisma_client: PrismaClient,
team_id: Optional[str],
@@ -3219,8 +3254,16 @@ async def _build_team_list_where_conditions(
organization_id: Optional[str],
user_id: Optional[str],
use_deleted_table: bool,
) -> Dict[str, Any]:
"""Build where conditions for team list query."""
org_admin_org_ids: Optional[List[str]] = None,
user_api_key_cache: Optional[Any] = None,
proxy_logging_obj: Optional[Any] = None,
) -> Optional[Dict[str, Any]]:
"""
Build where conditions for team list query.
Returns None when the query is guaranteed to yield no results (e.g. user
has no team memberships), allowing the caller to skip the DB round-trip.
"""
where_conditions: Dict[str, Any] = {}
if team_id:
@@ -3234,58 +3277,79 @@ async def _build_team_list_where_conditions(
if organization_id:
where_conditions["organization_id"] = organization_id
elif org_admin_org_ids is not None and not user_id:
# Org admin without explicit org or user filter: scope to their orgs.
# NOTE: when user_id is provided, no org filter is applied — the
# query returns all teams the target user belongs to across all
# organisations. This matches the legacy /team/list behaviour in
# _authorize_and_filter_teams which fetches direct-membership teams
# without an org constraint.
where_conditions["organization_id"] = {"in": org_admin_org_ids}
if user_id:
try:
user_object = await prisma_client.db.litellm_usertable.find_unique(
where={"user_id": user_id}
user_object_correct_type = await get_user_object(
user_id=user_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
user_id_upsert=False,
proxy_logging_obj=proxy_logging_obj,
)
except Exception:
except ValueError:
raise HTTPException(
status_code=404,
detail={"error": f"User not found, passed user_id={user_id}"},
)
if user_object is None:
if user_object_correct_type is None:
raise HTTPException(
status_code=404,
detail={"error": f"User not found, passed user_id={user_id}"},
)
user_object_correct_type = LiteLLM_UserTable(**user_object.model_dump())
user_team_ids = user_object_correct_type.teams or []
if use_deleted_table:
where_conditions["members"] = {"has": user_id}
else:
if team_id is None:
where_conditions["team_id"] = {"in": user_object_correct_type.teams}
elif team_id in user_object_correct_type.teams:
where_conditions["team_id"] = team_id
# When user_id is provided, filter by that user's direct team
# memberships. For org admins the access control gate in
# list_team_v2 already verified the caller's authority — the
# filter logic is the same as for regular users.
if not user_team_ids:
return None # no memberships — skip the DB query
elif team_id is not None:
# team_id exact-match already in where_conditions; verify membership
if team_id not in user_team_ids:
raise HTTPException(
status_code=404,
detail={"error": f"User is not a member of team_id={team_id}"},
)
else:
raise HTTPException(
status_code=404,
detail={"error": f"User is not a member of team_id={team_id}"},
)
where_conditions["team_id"] = {"in": user_team_ids}
return where_conditions
def _convert_teams_to_response(
teams: List[Any], use_deleted_table: bool
) -> List[Union[LiteLLM_TeamTable, LiteLLM_DeletedTeamTable]]:
"""Convert Prisma models to Pydantic models."""
team_list: List[Union[LiteLLM_TeamTable, LiteLLM_DeletedTeamTable]] = []
if teams:
for team in teams:
# Convert Prisma model to dict (supports both Pydantic v1 and v2)
try:
team_dict = team.model_dump()
except Exception:
# Fallback for Pydantic v1 compatibility
team_dict = team.dict()
if use_deleted_table:
# Use deleted team type to preserve deleted_at, deleted_by, etc.
team_list.append(LiteLLM_DeletedTeamTable(**team_dict))
else:
team_list.append(LiteLLM_TeamTable(**team_dict))
def _convert_teams_to_response_models(
teams: list,
use_deleted_table: bool,
) -> List[Union[TeamListItem, LiteLLM_TeamTable, LiteLLM_DeletedTeamTable]]:
"""Convert raw Prisma team rows to response models."""
team_list: List[Union[TeamListItem, LiteLLM_TeamTable, LiteLLM_DeletedTeamTable]] = []
for team in teams:
try:
team_dict = team.model_dump()
except Exception:
team_dict = team.dict()
if use_deleted_table:
team_list.append(LiteLLM_DeletedTeamTable(**team_dict))
else:
members_with_roles = team_dict.get("members_with_roles")
if not isinstance(members_with_roles, list):
members_with_roles = []
team_dict["members_with_roles"] = members_with_roles
members_count = len(members_with_roles)
team_list.append(TeamListItem(**team_dict, members_count=members_count))
return team_list
@@ -3353,7 +3417,11 @@ async def list_team_v2(
status: Optional[str]
Filter by status. Currently supports "deleted" to query deleted teams.
"""
from litellm.proxy.proxy_server import prisma_client
from litellm.proxy.proxy_server import (
prisma_client,
proxy_logging_obj,
user_api_key_cache,
)
if prisma_client is None:
raise HTTPException(
@@ -3361,20 +3429,55 @@ async def list_team_v2(
detail={"error": f"No db connected. prisma client={prisma_client}"},
)
if not allowed_route_check_inside_route(
user_api_key_dict=user_api_key_dict, requested_user_id=user_id
):
raise HTTPException(
status_code=401,
detail={
"error": "Only admin users can query all teams/other teams. Your user role={}".format(
user_api_key_dict.user_role
)
},
)
# --- Access control ---
# Proxy admins and admin viewers can query any teams.
# Org admins can query teams within their organizations.
# Regular users can only query their own teams.
is_proxy_admin = _user_has_admin_view(user_api_key_dict)
org_admin_org_ids: Optional[List[str]] = None
if user_id is None and user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN:
user_id = user_api_key_dict.user_id
if not is_proxy_admin:
# Always check org admin status so that even own-queries see
# the full set of organisation teams, not just direct memberships.
if user_api_key_dict.user_id:
org_admin_org_ids = await _get_org_admin_org_ids(
user_id=user_api_key_dict.user_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
)
if org_admin_org_ids is not None:
# Org admin: validate org_id filter if provided
if organization_id and organization_id not in org_admin_org_ids:
raise HTTPException(
status_code=403,
detail={
"error": "You can only view teams within your organizations."
},
)
verbose_proxy_logger.debug(
"list_team_v2: org admin access for user=%s, org_ids=%s, user_id_filter=%s",
user_api_key_dict.user_id,
org_admin_org_ids,
user_id,
)
else:
# Not an org admin — fall back to standard route check
if not allowed_route_check_inside_route(
user_api_key_dict=user_api_key_dict, requested_user_id=user_id
):
raise HTTPException(
status_code=401,
detail={
"error": "Only admin users can query all teams/other teams. Your user role={}".format(
user_api_key_dict.user_role
)
},
)
# Regular user — auto-inject caller's user_id
if user_id is None:
user_id = user_api_key_dict.user_id
if status is not None and status != "deleted":
raise HTTPException(
@@ -3389,7 +3492,8 @@ async def list_team_v2(
# Calculate skip and take for pagination
skip = (page - 1) * page_size
# Build where conditions based on provided parameters
# Build where conditions based on provided parameters.
# Returns None when the query is guaranteed to yield no results.
where_conditions = await _build_team_list_where_conditions(
prisma_client=prisma_client,
team_id=team_id,
@@ -3397,8 +3501,20 @@ async def list_team_v2(
organization_id=organization_id,
user_id=user_id,
use_deleted_table=use_deleted_table,
org_admin_org_ids=org_admin_org_ids,
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
)
if where_conditions is None:
return {
"teams": [],
"total": 0,
"page": page,
"page_size": page_size,
"total_pages": 0,
}
# Build order_by conditions
valid_sort_columns = ["team_id", "team_alias", "created_at"]
order_by = None
@@ -3434,8 +3550,8 @@ async def list_team_v2(
# Calculate total pages
total_pages = -(-total_count // page_size) # Ceiling division
# Convert Prisma models to Pydantic models, preserving deleted fields when applicable
team_list = _convert_teams_to_response(teams, use_deleted_table)
# Convert Prisma models to response models with members_count
team_list = _convert_teams_to_response_models(teams, use_deleted_table)
return {
"teams": team_list,
+4
View File
@@ -146,6 +146,10 @@ model LiteLLM_TeamTable {
litellm_model_table LiteLLM_ModelTable? @relation(fields: [model_id], references: [id])
object_permission LiteLLM_ObjectPermissionTable? @relation(fields: [object_permission_id], references: [object_permission_id])
projects LiteLLM_ProjectTable[]
@@index([organization_id])
@@index([team_alias])
@@index([created_at])
}
// Projects sit between teams and keys for use-case management
@@ -43,10 +43,16 @@ class UpdateTeamMemberPermissionsRequest(BaseModel):
team_member_permissions: List[str]
class TeamListItem(LiteLLM_TeamTable):
"""A team item in the paginated list response, enriched with computed fields."""
members_count: int = 0
class TeamListResponse(BaseModel):
"""Response to get the list of teams"""
teams: List[Union[LiteLLM_TeamTable, LiteLLM_DeletedTeamTable]]
teams: List[Union[TeamListItem, LiteLLM_TeamTable, LiteLLM_DeletedTeamTable]]
total: int
page: int
page_size: int
Generated
+4 -4
View File
@@ -3222,15 +3222,15 @@ files = [
[[package]]
name = "litellm-proxy-extras"
version = "0.4.57"
version = "0.4.58"
description = "Additional files for the LiteLLM Proxy. Reduces the size of the main litellm package."
optional = true
python-versions = "!=2.7.*,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,!=3.7.*,>=3.8"
groups = ["main"]
markers = "extra == \"proxy\""
files = [
{file = "litellm_proxy_extras-0.4.57-py3-none-any.whl", hash = "sha256:04538223cd80318a72d70c6e10f701598e58c763368296a6503c674c92fbdb62"},
{file = "litellm_proxy_extras-0.4.57.tar.gz", hash = "sha256:ef9b95dc42237614216833bd5d46ebf9dea1caa5ea14ea1a66d7f7842b224ec2"},
{file = "litellm_proxy_extras-0.4.58-py3-none-any.whl", hash = "sha256:8863e70de833c0e35119a1cbbf583619bdebe52222efd5654586519175ba403b"},
{file = "litellm_proxy_extras-0.4.58.tar.gz", hash = "sha256:84a67483329eced8be4fc61c4e43f117287aa4e3deeb8ddf8fe8cdc9a8508836"},
]
[[package]]
@@ -8018,4 +8018,4 @@ utils = ["numpydoc"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.9,<4.0"
content-hash = "0002021a7733b370a9855b26198e8e6dc49a62b67f1eface6f2fbe406ff3c3ac"
content-hash = "eda34dfd8b35474beffee18893d6782c7b3d0d3d2c610f66237eb97176f43527"
+1 -1
View File
@@ -61,7 +61,7 @@ boto3 = { version = "^1.40.76", optional = true }
redisvl = {version = "^0.4.1", optional = true, markers = "python_version >= '3.9' and python_version < '3.14'"}
mcp = {version = ">=1.25.0,<2.0.0", optional = true, python = ">=3.10"}
a2a-sdk = {version = "^0.3.22", optional = true, python = ">=3.10"}
litellm-proxy-extras = {version = "^0.4.57", optional = true}
litellm-proxy-extras = {version = "^0.4.58", optional = true}
rich = {version = "^13.7.1", optional = true}
litellm-enterprise = {version = "^0.1.33", optional = true}
diskcache = {version = "^5.6.1", optional = true}
+1 -1
View File
@@ -57,7 +57,7 @@ grpcio>=1.75.0; python_version >= "3.14"
sentry_sdk==2.21.0 # for sentry error handling
detect-secrets==1.5.0 # Enterprise - secret detection / masking in LLM requests
tzdata==2025.1 # IANA time zone database
litellm-proxy-extras==0.4.57 # for proxy extras - e.g. prisma migrations
litellm-proxy-extras==0.4.58 # for proxy extras - e.g. prisma migrations
llm-sandbox==0.3.31 # for skill execution in sandbox
### LITELLM PACKAGE DEPENDENCIES
python-dotenv==1.0.1 # for env
+4
View File
@@ -146,6 +146,10 @@ model LiteLLM_TeamTable {
litellm_model_table LiteLLM_ModelTable? @relation(fields: [model_id], references: [id])
object_permission LiteLLM_ObjectPermissionTable? @relation(fields: [object_permission_id], references: [object_permission_id])
projects LiteLLM_ProjectTable[]
@@index([organization_id])
@@index([team_alias])
@@index([created_at])
}
// Projects sit between teams and keys for use-case management
@@ -2062,7 +2062,14 @@ async def test_list_team_v2_security_check_non_admin_user():
user_id="non_admin_user_123",
)
with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma_client:
with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma_client, \
patch("litellm.proxy.proxy_server.user_api_key_cache"), \
patch("litellm.proxy.proxy_server.proxy_logging_obj"), \
patch(
"litellm.proxy.management_endpoints.team_endpoints.get_user_object",
new_callable=AsyncMock,
return_value=None,
):
mock_prisma_client.return_value = MagicMock() # Mock non-None prisma client
# Should raise HTTPException with 401 status
@@ -2103,7 +2110,14 @@ async def test_list_team_v2_security_check_non_admin_user_other_user():
user_id="non_admin_user_123",
)
with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma_client:
with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma_client, \
patch("litellm.proxy.proxy_server.user_api_key_cache"), \
patch("litellm.proxy.proxy_server.proxy_logging_obj"), \
patch(
"litellm.proxy.management_endpoints.team_endpoints.get_user_object",
new_callable=AsyncMock,
return_value=None,
):
mock_prisma_client.return_value = MagicMock() # Mock non-None prisma client
# Should raise HTTPException with 401 status
@@ -2142,19 +2156,21 @@ async def test_list_team_v2_security_check_non_admin_user_own_teams():
user_id="non_admin_user_123",
)
with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma_client:
with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma_client, \
patch("litellm.proxy.proxy_server.user_api_key_cache"), \
patch("litellm.proxy.proxy_server.proxy_logging_obj"):
# Mock prisma client and database operations
mock_db = Mock()
mock_prisma_client.db = mock_db
# Mock user lookup
mock_user_object = Mock()
mock_user_object.model_dump.return_value = {
"user_id": "non_admin_user_123",
"teams": ["team_1", "team_2"],
}
mock_db.litellm_usertable.find_unique = AsyncMock(return_value=mock_user_object)
# Mock get_user_object to return a user with teams
from litellm.proxy._types import LiteLLM_UserTable
mock_user = LiteLLM_UserTable(
user_id="non_admin_user_123",
teams=["team_1", "team_2"],
)
# Mock team lookup
mock_teams = [
Mock(model_dump=lambda: {"team_id": "team_1", "team_alias": "Team 1"}),
@@ -2163,21 +2179,26 @@ async def test_list_team_v2_security_check_non_admin_user_own_teams():
mock_db.litellm_teamtable.find_many = AsyncMock(return_value=mock_teams)
mock_db.litellm_teamtable.count = AsyncMock(return_value=2)
# Should NOT raise an exception
result = await list_team_v2(
http_request=mock_request,
user_id="non_admin_user_123", # Non-admin querying their own teams
user_api_key_dict=mock_user_api_key_dict_non_admin,
team_id=None,
page=1,
page_size=10,
status=None,
)
with patch(
"litellm.proxy.management_endpoints.team_endpoints.get_user_object",
new_callable=AsyncMock,
return_value=mock_user,
):
# Should NOT raise an exception
result = await list_team_v2(
http_request=mock_request,
user_id="non_admin_user_123", # Non-admin querying their own teams
user_api_key_dict=mock_user_api_key_dict_non_admin,
team_id=None,
page=1,
page_size=10,
status=None,
)
# Should return results without error
assert "teams" in result
assert "total" in result
assert result["total"] == 2
# Should return results without error
assert "teams" in result
assert "total" in result
assert result["total"] == 2
@pytest.mark.asyncio
@@ -2293,27 +2314,280 @@ async def test_list_team_v2_with_status_deleted():
assert len(result["teams"]) == 2
@pytest.mark.asyncio
async def test_list_team_v2_org_admin_sees_org_teams():
"""
Test that an org admin (internal_user role with org_admin membership)
can list teams scoped to their organisations without getting a 401.
"""
from datetime import datetime
from unittest.mock import AsyncMock, Mock, patch
from fastapi import Request
from litellm.proxy._types import (
LiteLLM_OrganizationMembershipTable,
LiteLLM_UserTable,
LitellmUserRoles,
UserAPIKeyAuth,
)
from litellm.proxy.management_endpoints.team_endpoints import list_team_v2
mock_request = Mock(spec=Request)
mock_user_api_key_dict = UserAPIKeyAuth(
user_role=LitellmUserRoles.INTERNAL_USER,
user_id="org_admin_user",
)
mock_user = LiteLLM_UserTable(
user_id="org_admin_user",
teams=[],
organization_memberships=[
LiteLLM_OrganizationMembershipTable(
user_id="org_admin_user",
organization_id="org_A",
user_role="org_admin",
spend=0.0,
created_at=datetime.now(),
updated_at=datetime.now(),
),
],
)
with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, \
patch("litellm.proxy.proxy_server.user_api_key_cache"), \
patch("litellm.proxy.proxy_server.proxy_logging_obj"), \
patch(
"litellm.proxy.management_endpoints.team_endpoints.get_user_object",
new_callable=AsyncMock,
return_value=mock_user,
):
mock_db = Mock()
mock_prisma.db = mock_db
mock_team = Mock()
mock_team.model_dump.return_value = {
"team_id": "team_in_org_A",
"team_alias": "Org A Team",
"organization_id": "org_A",
"members_with_roles": [{"user_id": "u1", "role": "user"}],
}
mock_db.litellm_teamtable.find_many = AsyncMock(return_value=[mock_team])
mock_db.litellm_teamtable.count = AsyncMock(return_value=1)
result = await list_team_v2(
http_request=mock_request,
user_id=None,
organization_id=None,
team_id=None,
team_alias=None,
user_api_key_dict=mock_user_api_key_dict,
page=1,
page_size=10,
sort_by=None,
sort_order="asc",
status=None,
)
assert result["total"] == 1
assert len(result["teams"]) == 1
assert result["teams"][0].members_count == 1
# Verify org-scoped where clause
where = mock_db.litellm_teamtable.find_many.call_args.kwargs["where"]
assert where["organization_id"] == {"in": ["org_A"]}
@pytest.mark.asyncio
async def test_list_team_v2_org_admin_cannot_view_other_orgs():
"""
Test that an org admin is rejected with 403 when filtering by an
organisation they do not administer.
"""
from datetime import datetime
from unittest.mock import AsyncMock, Mock, patch
from fastapi import HTTPException, Request
from litellm.proxy._types import (
LiteLLM_OrganizationMembershipTable,
LiteLLM_UserTable,
LitellmUserRoles,
UserAPIKeyAuth,
)
from litellm.proxy.management_endpoints.team_endpoints import list_team_v2
mock_request = Mock(spec=Request)
mock_user_api_key_dict = UserAPIKeyAuth(
user_role=LitellmUserRoles.INTERNAL_USER,
user_id="org_admin_user",
)
mock_user = LiteLLM_UserTable(
user_id="org_admin_user",
teams=[],
organization_memberships=[
LiteLLM_OrganizationMembershipTable(
user_id="org_admin_user",
organization_id="org_A",
user_role="org_admin",
spend=0.0,
created_at=datetime.now(),
updated_at=datetime.now(),
),
],
)
with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, \
patch("litellm.proxy.proxy_server.user_api_key_cache"), \
patch("litellm.proxy.proxy_server.proxy_logging_obj"), \
patch(
"litellm.proxy.management_endpoints.team_endpoints.get_user_object",
new_callable=AsyncMock,
return_value=mock_user,
):
mock_prisma.db = Mock()
with pytest.raises(HTTPException) as exc_info:
await list_team_v2(
http_request=mock_request,
user_id=None,
organization_id="org_B", # not their org
team_id=None,
team_alias=None,
user_api_key_dict=mock_user_api_key_dict,
page=1,
page_size=10,
sort_by=None,
sort_order="asc",
status=None,
)
assert exc_info.value.status_code == 403
assert "only view teams within your organizations" in str(
exc_info.value.detail
).lower()
@pytest.mark.asyncio
async def test_list_team_v2_org_admin_with_user_id_returns_user_teams():
"""
Test that an org admin passing user_id gets that user's direct team
memberships (not all org teams).
"""
from datetime import datetime
from unittest.mock import AsyncMock, Mock, patch
from fastapi import Request
from litellm.proxy._types import (
LiteLLM_OrganizationMembershipTable,
LiteLLM_UserTable,
LitellmUserRoles,
UserAPIKeyAuth,
)
from litellm.proxy.management_endpoints.team_endpoints import list_team_v2
mock_request = Mock(spec=Request)
mock_user_api_key_dict = UserAPIKeyAuth(
user_role=LitellmUserRoles.INTERNAL_USER,
user_id="org_admin_user",
)
mock_org_admin = LiteLLM_UserTable(
user_id="org_admin_user",
teams=["team_1"],
organization_memberships=[
LiteLLM_OrganizationMembershipTable(
user_id="org_admin_user",
organization_id="org_A",
user_role="org_admin",
spend=0.0,
created_at=datetime.now(),
updated_at=datetime.now(),
),
],
)
# The target user whose teams we want to list
mock_target_user = LiteLLM_UserTable(
user_id="target_user",
teams=["team_X", "team_Y"],
)
call_count = 0
async def mock_get_user_object(**kwargs):
nonlocal call_count
call_count += 1
# First call: org admin lookup in list_team_v2
# Second call: target user lookup in _build_team_list_where_conditions
if call_count == 1:
return mock_org_admin
return mock_target_user
with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, \
patch("litellm.proxy.proxy_server.user_api_key_cache"), \
patch("litellm.proxy.proxy_server.proxy_logging_obj"), \
patch(
"litellm.proxy.management_endpoints.team_endpoints.get_user_object",
side_effect=mock_get_user_object,
):
mock_db = Mock()
mock_prisma.db = mock_db
mock_team = Mock()
mock_team.model_dump.return_value = {
"team_id": "team_X",
"team_alias": "Target Team",
"members_with_roles": [{"user_id": "target_user", "role": "user"}],
}
mock_db.litellm_teamtable.find_many = AsyncMock(return_value=[mock_team])
mock_db.litellm_teamtable.count = AsyncMock(return_value=1)
result = await list_team_v2(
http_request=mock_request,
user_id="target_user",
organization_id=None,
team_id=None,
team_alias=None,
user_api_key_dict=mock_user_api_key_dict,
page=1,
page_size=10,
sort_by=None,
sort_order="asc",
status=None,
)
assert result["total"] == 1
# Verify the where clause filters by user's teams, not org scope
where = mock_db.litellm_teamtable.find_many.call_args.kwargs["where"]
assert where["team_id"] == {"in": ["team_X", "team_Y"]}
assert "organization_id" not in where
@pytest.mark.asyncio
async def test_list_team_v2_with_invalid_status():
"""
Test that invalid status parameter raises HTTPException.
"""
from unittest.mock import Mock, patch
from fastapi import HTTPException, Request
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.management_endpoints.team_endpoints import list_team_v2
# Mock request
mock_request = Mock(spec=Request)
# Mock admin user
mock_user_api_key_dict_admin = UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN,
user_id="admin_user_123",
)
mock_prisma_client = Mock()
# Mock prisma_client to be non-None
@@ -0,0 +1,41 @@
import { renderWithProviders, screen } from "../../tests/test-utils";
import { vi } from "vitest";
import { DebugWarningBanner } from "./DebugWarningBanner";
vi.mock("@/app/(dashboard)/hooks/healthReadiness/useHealthReadiness", () => ({
useHealthReadiness: vi.fn(),
}));
import { useHealthReadiness } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadiness";
describe("DebugWarningBanner", () => {
it("should render", () => {
vi.mocked(useHealthReadiness).mockReturnValue({ data: { is_detailed_debug: true } } as any);
renderWithProviders(<DebugWarningBanner />);
expect(screen.getByRole("alert")).toBeInTheDocument();
});
it("should show warning when detailed debug mode is active", () => {
vi.mocked(useHealthReadiness).mockReturnValue({ data: { is_detailed_debug: true } } as any);
renderWithProviders(<DebugWarningBanner />);
expect(screen.getByText(/Performance Warning: Detailed Debug Mode Active/i)).toBeInTheDocument();
});
it("should mention LITELLM_LOG=DEBUG in the description", () => {
vi.mocked(useHealthReadiness).mockReturnValue({ data: { is_detailed_debug: true } } as any);
renderWithProviders(<DebugWarningBanner />);
expect(screen.getByText("LITELLM_LOG=DEBUG")).toBeInTheDocument();
});
it("should render nothing when is_detailed_debug is false", () => {
vi.mocked(useHealthReadiness).mockReturnValue({ data: { is_detailed_debug: false } } as any);
const { container } = renderWithProviders(<DebugWarningBanner />);
expect(container).toBeEmptyDOMElement();
});
it("should render nothing when health data is undefined", () => {
vi.mocked(useHealthReadiness).mockReturnValue({ data: undefined } as any);
const { container } = renderWithProviders(<DebugWarningBanner />);
expect(container).toBeEmptyDOMElement();
});
});
@@ -0,0 +1,20 @@
import { renderWithProviders, screen } from "../../../tests/test-utils";
import { vi } from "vitest";
import ExportFormatSelector from "./ExportFormatSelector";
describe("ExportFormatSelector", () => {
it("should render", () => {
renderWithProviders(<ExportFormatSelector value="csv" onChange={vi.fn()} />);
expect(screen.getByText("Format")).toBeInTheDocument();
});
it("should display the current value", () => {
renderWithProviders(<ExportFormatSelector value="csv" onChange={vi.fn()} />);
expect(screen.getByText("CSV (Excel, Google Sheets)")).toBeInTheDocument();
});
it("should display JSON label when json is selected", () => {
renderWithProviders(<ExportFormatSelector value="json" onChange={vi.fn()} />);
expect(screen.getByText("JSON (includes metadata)")).toBeInTheDocument();
});
});
@@ -0,0 +1,59 @@
import { renderWithProviders, screen } from "../../../tests/test-utils";
import ExportSummary from "./ExportSummary";
describe("ExportSummary", () => {
it("should render", () => {
const dateRange = {
from: new Date("2024-01-01"),
to: new Date("2024-01-31"),
};
const { container } = renderWithProviders(
<ExportSummary dateRange={dateRange} selectedFilters={[]} />
);
expect(container).not.toBeEmptyDOMElement();
});
it("should display the date range", () => {
const from = new Date(2024, 0, 1);
const to = new Date(2024, 0, 31);
const dateRange = { from, to };
renderWithProviders(
<ExportSummary dateRange={dateRange} selectedFilters={[]} />
);
expect(screen.getByText(new RegExp(from.toLocaleDateString()))).toBeInTheDocument();
expect(screen.getByText(new RegExp(to.toLocaleDateString()))).toBeInTheDocument();
});
it("should show filter count when filters are selected", () => {
const dateRange = {
from: new Date("2024-01-01"),
to: new Date("2024-01-31"),
};
renderWithProviders(
<ExportSummary dateRange={dateRange} selectedFilters={["team-a", "team-b", "team-c"]} />
);
expect(screen.getByText(/3 filters/)).toBeInTheDocument();
});
it("should show singular 'filter' for one filter", () => {
const dateRange = {
from: new Date("2024-01-01"),
to: new Date("2024-01-31"),
};
renderWithProviders(
<ExportSummary dateRange={dateRange} selectedFilters={["team-a"]} />
);
expect(screen.getByText(/1 filter$/)).toBeInTheDocument();
});
it("should not show filter count when no filters selected", () => {
const dateRange = {
from: new Date("2024-01-01"),
to: new Date("2024-01-31"),
};
renderWithProviders(
<ExportSummary dateRange={dateRange} selectedFilters={[]} />
);
expect(screen.queryByText(/filter/)).not.toBeInTheDocument();
});
});
@@ -0,0 +1,46 @@
import { renderWithProviders, screen } from "../../../tests/test-utils";
import userEvent from "@testing-library/user-event";
import { vi } from "vitest";
import ExportTypeSelector from "./ExportTypeSelector";
describe("ExportTypeSelector", () => {
it("should render", () => {
renderWithProviders(
<ExportTypeSelector value="daily" onChange={vi.fn()} entityType="team" />
);
expect(screen.getByText("Export type")).toBeInTheDocument();
});
it("should display entity type in radio labels", () => {
renderWithProviders(
<ExportTypeSelector value="daily" onChange={vi.fn()} entityType="team" />
);
expect(screen.getByText(/Day-by-day breakdown by team$/)).toBeInTheDocument();
expect(screen.getByText(/Day-by-day breakdown by team and key/)).toBeInTheDocument();
expect(screen.getByText(/Day-by-day by team and model/)).toBeInTheDocument();
});
it("should display the correct entity type for different entities", () => {
renderWithProviders(
<ExportTypeSelector value="daily" onChange={vi.fn()} entityType="organization" />
);
expect(screen.getByText(/Day-by-day breakdown by organization$/)).toBeInTheDocument();
});
it("should call onChange when a radio option is selected", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
renderWithProviders(
<ExportTypeSelector value="daily" onChange={onChange} entityType="team" />
);
await user.click(screen.getByRole("radio", { name: /Day-by-day breakdown by team and key/i }));
expect(onChange).toHaveBeenCalledWith("daily_with_keys");
});
it("should have the correct radio checked", () => {
renderWithProviders(
<ExportTypeSelector value="daily_with_models" onChange={vi.fn()} entityType="team" />
);
expect(screen.getByRole("radio", { name: /Day-by-day by team and model/i })).toBeChecked();
});
});
@@ -0,0 +1,49 @@
import { renderWithProviders, screen } from "../../../tests/test-utils";
import React from "react";
import { MetricCard } from "./MetricCard";
describe("MetricCard", () => {
it("should render", () => {
renderWithProviders(<MetricCard label="Total Requests" value={1234} />);
expect(screen.getByText("Total Requests")).toBeInTheDocument();
});
it("should display the label and value", () => {
renderWithProviders(<MetricCard label="Success Rate" value="98.5%" />);
expect(screen.getByText("Success Rate")).toBeInTheDocument();
expect(screen.getByText("98.5%")).toBeInTheDocument();
});
it("should display numeric values", () => {
renderWithProviders(<MetricCard label="Count" value={42} />);
expect(screen.getByText("42")).toBeInTheDocument();
});
it("should render icon when provided", () => {
renderWithProviders(
<MetricCard
label="Metric"
value={100}
icon={<span data-testid="test-icon">icon</span>}
/>
);
expect(screen.getByTestId("test-icon")).toBeInTheDocument();
});
it("should not render icon container when no icon provided", () => {
renderWithProviders(<MetricCard label="Metric" value={100} />);
expect(screen.queryByTestId("test-icon")).not.toBeInTheDocument();
});
it("should render subtitle when provided", () => {
renderWithProviders(
<MetricCard label="Metric" value={100} subtitle="Last 24 hours" />
);
expect(screen.getByText("Last 24 hours")).toBeInTheDocument();
});
it("should not render subtitle when not provided", () => {
renderWithProviders(<MetricCard label="Metric" value={100} />);
expect(screen.queryByText("Last 24 hours")).not.toBeInTheDocument();
});
});
@@ -23,6 +23,11 @@ describe("HelpLink", () => {
expect(screen.getByText("Custom docs link")).toBeInTheDocument();
});
it("should have the correct href", () => {
renderWithProviders(<HelpLink href="https://docs.example.com/test" />);
expect(screen.getByRole("link")).toHaveAttribute("href", "https://docs.example.com/test");
});
it("should include a screen-reader-only label for accessibility", () => {
renderWithProviders(<HelpLink href="https://docs.example.com" />);
@@ -46,7 +51,21 @@ describe("HelpIcon", () => {
expect(screen.getByText("Tooltip help text")).toBeInTheDocument();
});
it("should hide tooltip content when not hovered", () => {
renderWithProviders(<HelpIcon content="Hidden tooltip" />);
expect(screen.queryByText("Hidden tooltip")).not.toBeInTheDocument();
});
it("should show learn more link when learnMoreHref is provided", async () => {
const user = userEvent.setup();
renderWithProviders(
<HelpIcon content="Help text" learnMoreHref="https://docs.example.com" />
);
await user.hover(screen.getByRole("button", { name: /help information/i }));
expect(screen.getByText("Learn more")).toBeInTheDocument();
});
it("should use custom learn more text when provided", async () => {
const user = userEvent.setup();
renderWithProviders(
<HelpIcon
@@ -84,6 +103,11 @@ describe("DocsMenu", () => {
expect(screen.getByRole("button", { name: /docs/i })).toBeInTheDocument();
});
it("should hide menu items initially", () => {
renderWithProviders(<DocsMenu items={items} />);
expect(screen.queryByText("Custom pricing")).not.toBeInTheDocument();
});
it("should show menu items when button is clicked", async () => {
const user = userEvent.setup();
renderWithProviders(<DocsMenu items={items} />);
@@ -0,0 +1,84 @@
import { renderWithProviders, screen } from "../../../tests/test-utils";
import { vi } from "vitest";
import {
PolicySelect,
policyStyle,
INPUT_POLICY_OPTIONS,
OUTPUT_POLICY_OPTIONS,
} from "./PolicySelect";
describe("policyStyle", () => {
it("should return the matching option for a known policy", () => {
expect(policyStyle("trusted")).toEqual(INPUT_POLICY_OPTIONS[1]);
});
it("should return the matching option for blocked", () => {
expect(policyStyle("blocked")).toEqual(INPUT_POLICY_OPTIONS[2]);
});
it("should return the first option as fallback for unknown policy", () => {
expect(policyStyle("unknown")).toEqual(INPUT_POLICY_OPTIONS[0]);
});
});
describe("PolicySelect", () => {
it("should render", () => {
renderWithProviders(
<PolicySelect
value="untrusted"
toolName="test-tool"
saving={false}
onChange={vi.fn()}
/>
);
expect(screen.getByText("untrusted")).toBeInTheDocument();
});
it("should show the current policy value", () => {
renderWithProviders(
<PolicySelect
value="trusted"
toolName="test-tool"
saving={false}
onChange={vi.fn()}
/>
);
expect(screen.getByText("trusted")).toBeInTheDocument();
});
it("should be disabled when saving is true", () => {
renderWithProviders(
<PolicySelect
value="untrusted"
toolName="test-tool"
saving={true}
onChange={vi.fn()}
/>
);
expect(screen.getByRole("combobox")).toHaveAttribute("aria-expanded", "false");
expect(screen.getByRole("combobox").closest(".ant-select")).toHaveClass("ant-select-disabled");
});
it("should not be disabled when saving is false", () => {
renderWithProviders(
<PolicySelect
value="untrusted"
toolName="test-tool"
saving={false}
onChange={vi.fn()}
/>
);
expect(screen.getByRole("combobox").closest(".ant-select")).not.toHaveClass("ant-select-disabled");
});
});
describe("Policy option constants", () => {
it("should have 3 input policy options", () => {
expect(INPUT_POLICY_OPTIONS).toHaveLength(3);
});
it("should have 2 output policy options (no blocked)", () => {
expect(OUTPUT_POLICY_OPTIONS).toHaveLength(2);
expect(OUTPUT_POLICY_OPTIONS.map((o) => o.value)).toEqual(["untrusted", "trusted"]);
});
});
@@ -0,0 +1,82 @@
import { renderWithProviders, screen } from "../../../tests/test-utils";
import { vi } from "vitest";
import ComplexityRouterConfig from "./ComplexityRouterConfig";
const mockModelInfo = [
{ model_group: "gpt-4" },
{ model_group: "gpt-3.5-turbo" },
{ model_group: "claude-3-opus" },
] as any[];
const defaultTiers = {
SIMPLE: "gpt-3.5-turbo",
MEDIUM: "gpt-3.5-turbo",
COMPLEX: "gpt-4",
REASONING: "claude-3-opus",
};
describe("ComplexityRouterConfig", () => {
it("should render", () => {
renderWithProviders(
<ComplexityRouterConfig
modelInfo={mockModelInfo}
value={defaultTiers}
onChange={vi.fn()}
/>
);
expect(screen.getByText("Complexity Tier Configuration")).toBeInTheDocument();
});
it("should display all four tier labels", () => {
renderWithProviders(
<ComplexityRouterConfig
modelInfo={mockModelInfo}
value={defaultTiers}
onChange={vi.fn()}
/>
);
expect(screen.getByText("Simple Tier")).toBeInTheDocument();
expect(screen.getByText("Medium Tier")).toBeInTheDocument();
expect(screen.getByText("Complex Tier")).toBeInTheDocument();
expect(screen.getByText("Reasoning Tier")).toBeInTheDocument();
});
it("should show example queries for each tier", () => {
renderWithProviders(
<ComplexityRouterConfig
modelInfo={mockModelInfo}
value={defaultTiers}
onChange={vi.fn()}
/>
);
expect(screen.getByText(/Hello!/)).toBeInTheDocument();
expect(screen.getByText(/Explain how REST APIs work/)).toBeInTheDocument();
expect(screen.getByText(/Design a microservices architecture/)).toBeInTheDocument();
expect(screen.getByText(/Think step by step/)).toBeInTheDocument();
});
it("should display the how classification works section", () => {
renderWithProviders(
<ComplexityRouterConfig
modelInfo={mockModelInfo}
value={defaultTiers}
onChange={vi.fn()}
/>
);
expect(screen.getByText("How Classification Works")).toBeInTheDocument();
});
it("should show score thresholds in the classification section", () => {
renderWithProviders(
<ComplexityRouterConfig
modelInfo={mockModelInfo}
value={defaultTiers}
onChange={vi.fn()}
/>
);
expect(screen.getByText(/Score < 0.15/)).toBeInTheDocument();
expect(screen.getByText(/Score 0.15 - 0.35/)).toBeInTheDocument();
expect(screen.getByText(/Score 0.35 - 0.60/)).toBeInTheDocument();
expect(screen.getByText(/Score > 0.60/)).toBeInTheDocument();
});
});
@@ -0,0 +1,90 @@
import { renderWithProviders, screen } from "../../../tests/test-utils";
import { vi } from "vitest";
import AgentCardGrid from "./agent_card_grid";
import type { Agent, AgentKeyInfo } from "./types";
vi.mock("./agent_card", () => ({
default: ({ agent, onAgentClick }: any) => (
<div data-testid={`agent-card-${agent.agent_id}`} onClick={() => onAgentClick(agent.agent_id)}>
{agent.agent_name}
</div>
),
}));
const mockAgents: Agent[] = [
{
agent_id: "agent-1",
agent_name: "Test Agent 1",
litellm_params: { model: "gpt-4" },
agent_card_params: { description: "First agent" },
},
{
agent_id: "agent-2",
agent_name: "Test Agent 2",
litellm_params: { model: "claude-3" },
agent_card_params: { description: "Second agent" },
},
];
const mockKeyInfoMap: Record<string, AgentKeyInfo> = {
"agent-1": { has_key: true, key_alias: "key-1" },
"agent-2": { has_key: false },
};
const defaultProps = {
agentsList: mockAgents,
keyInfoMap: mockKeyInfoMap,
isLoading: false,
onDeleteClick: vi.fn(),
accessToken: "test-token",
onAgentUpdated: vi.fn(),
isAdmin: true,
onAgentClick: vi.fn(),
};
describe("AgentCardGrid", () => {
it("should render", () => {
renderWithProviders(<AgentCardGrid {...defaultProps} />);
expect(screen.getByText("Test Agent 1")).toBeInTheDocument();
});
it("should render all agent cards", () => {
renderWithProviders(<AgentCardGrid {...defaultProps} />);
expect(screen.getByText("Test Agent 1")).toBeInTheDocument();
expect(screen.getByText("Test Agent 2")).toBeInTheDocument();
});
it("should show loading skeletons when isLoading is true", () => {
renderWithProviders(<AgentCardGrid {...defaultProps} isLoading={true} />);
expect(screen.queryByText("Test Agent 1")).not.toBeInTheDocument();
});
it("should show admin empty state message when no agents and isAdmin", () => {
renderWithProviders(
<AgentCardGrid {...defaultProps} agentsList={[]} isAdmin={true} />
);
expect(
screen.getByText("No agents found. Create one to get started.")
).toBeInTheDocument();
});
it("should show non-admin empty state message when no agents and not admin", () => {
renderWithProviders(
<AgentCardGrid {...defaultProps} agentsList={[]} isAdmin={false} />
);
expect(
screen.getByText("No agents found. Contact an admin to create agents.")
).toBeInTheDocument();
});
it("should call onAgentClick when a card is clicked", async () => {
const onAgentClick = vi.fn();
renderWithProviders(
<AgentCardGrid {...defaultProps} onAgentClick={onAgentClick} />
);
const { default: userEvent } = await import("@testing-library/user-event");
const user = userEvent.setup();
await user.click(screen.getByTestId("agent-card-agent-1"));
expect(onAgentClick).toHaveBeenCalledWith("agent-1");
});
});
@@ -0,0 +1,61 @@
import { renderWithProviders, screen } from "../../../tests/test-utils";
import userEvent from "@testing-library/user-event";
import { vi } from "vitest";
import { Form } from "antd";
import React from "react";
import { RateLimitTypeFormItem } from "./RateLimitTypeFormItem";
const Wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<Form>{children}</Form>
);
describe("RateLimitTypeFormItem", () => {
it("should render", () => {
renderWithProviders(
<Wrapper>
<RateLimitTypeFormItem type="tpm" name="tpm_type" />
</Wrapper>
);
expect(screen.getByText(/TPM Rate Limit Type/)).toBeInTheDocument();
});
it("should display TPM label for tpm type", () => {
renderWithProviders(
<Wrapper>
<RateLimitTypeFormItem type="tpm" name="tpm_type" />
</Wrapper>
);
expect(screen.getByText(/TPM Rate Limit Type/)).toBeInTheDocument();
});
it("should display RPM label for rpm type", () => {
renderWithProviders(
<Wrapper>
<RateLimitTypeFormItem type="rpm" name="rpm_type" />
</Wrapper>
);
expect(screen.getByText(/RPM Rate Limit Type/)).toBeInTheDocument();
});
it("should show the select placeholder by default", () => {
renderWithProviders(
<Wrapper>
<RateLimitTypeFormItem type="tpm" name="tpm_type" />
</Wrapper>
);
expect(screen.getByText("Select rate limit type")).toBeInTheDocument();
});
it("should call onChange when provided", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
renderWithProviders(
<Wrapper>
<RateLimitTypeFormItem type="tpm" name="tpm_type" onChange={onChange} />
</Wrapper>
);
await user.click(screen.getByRole("combobox"));
await user.click(screen.getByText("Guaranteed throughput"));
expect(onChange).toHaveBeenCalledWith("guaranteed_throughput");
});
});
@@ -60,6 +60,7 @@ import CodeInterpreterOutput from "./CodeInterpreterOutput";
import CodeInterpreterTool from "./CodeInterpreterTool";
import { generateCodeSnippet } from "./CodeSnippets";
import EndpointSelector from "./EndpointSelector";
import FilePreviewCard from "./FilePreviewCard";
import MCPEventsDisplay from "./MCPEventsDisplay";
import type { MCPEvent } from "../../mcp_tools/types";
import { EndpointType, getEndpointType } from "./mode_endpoint_mapping";
@@ -2231,67 +2232,19 @@ const ChatUI: React.FC<ChatUIProps> = ({
{/* Show file previews above input when files are uploaded */}
{endpointType === EndpointType.RESPONSES && responsesUploadedImage && (
<div className="mb-2">
<div className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg border border-gray-200">
<div className="relative inline-block">
{responsesUploadedImage.name.toLowerCase().endsWith(".pdf") ? (
<div className="w-10 h-10 rounded-md bg-red-500 flex items-center justify-center">
<FilePdfOutlined style={{ fontSize: "16px", color: "white" }} />
</div>
) : (
<img
src={responsesImagePreviewUrl || ""}
alt="Upload preview"
className="w-10 h-10 rounded-md border border-gray-200 object-cover"
/>
)}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-900 truncate">{responsesUploadedImage.name}</div>
<div className="text-xs text-gray-500">
{responsesUploadedImage.name.toLowerCase().endsWith(".pdf") ? "PDF" : "Image"}
</div>
</div>
<button
className="flex items-center justify-center w-6 h-6 text-gray-400 hover:text-gray-600 hover:bg-gray-200 rounded-full transition-colors"
onClick={handleRemoveResponsesImage}
>
<DeleteOutlined style={{ fontSize: "12px" }} />
</button>
</div>
</div>
<FilePreviewCard
file={responsesUploadedImage}
previewUrl={responsesImagePreviewUrl}
onRemove={handleRemoveResponsesImage}
/>
)}
{endpointType === EndpointType.CHAT && chatUploadedImage && (
<div className="mb-2">
<div className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg border border-gray-200">
<div className="relative inline-block">
{chatUploadedImage.name.toLowerCase().endsWith(".pdf") ? (
<div className="w-10 h-10 rounded-md bg-red-500 flex items-center justify-center">
<FilePdfOutlined style={{ fontSize: "16px", color: "white" }} />
</div>
) : (
<img
src={chatImagePreviewUrl || ""}
alt="Upload preview"
className="w-10 h-10 rounded-md border border-gray-200 object-cover"
/>
)}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-900 truncate">{chatUploadedImage.name}</div>
<div className="text-xs text-gray-500">
{chatUploadedImage.name.toLowerCase().endsWith(".pdf") ? "PDF" : "Image"}
</div>
</div>
<button
className="flex items-center justify-center w-6 h-6 text-gray-400 hover:text-gray-600 hover:bg-gray-200 rounded-full transition-colors"
onClick={handleRemoveChatImage}
>
<DeleteOutlined style={{ fontSize: "12px" }} />
</button>
</div>
</div>
<FilePreviewCard
file={chatUploadedImage}
previewUrl={chatImagePreviewUrl}
onRemove={handleRemoveChatImage}
/>
)}
{/* Code Interpreter indicator and sample prompts when enabled */}
@@ -0,0 +1,70 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import FilePreviewCard from "./FilePreviewCard";
function makeFile(name: string): File {
return new File(["dummy"], name, { type: "application/octet-stream" });
}
describe("FilePreviewCard", () => {
it("should render", () => {
render(
<FilePreviewCard file={makeFile("photo.png")} previewUrl={null} onRemove={vi.fn()} />
);
expect(screen.getByText("photo.png")).toBeInTheDocument();
});
it("should display the file name", () => {
render(
<FilePreviewCard file={makeFile("my-screenshot.jpg")} previewUrl={null} onRemove={vi.fn()} />
);
expect(screen.getByText("my-screenshot.jpg")).toBeInTheDocument();
});
it("should show 'Image' label for non-PDF files", () => {
render(
<FilePreviewCard file={makeFile("photo.png")} previewUrl="blob:http://localhost/abc" onRemove={vi.fn()} />
);
expect(screen.getByText("Image")).toBeInTheDocument();
});
it("should show 'PDF' label for PDF files", () => {
render(
<FilePreviewCard file={makeFile("report.pdf")} previewUrl={null} onRemove={vi.fn()} />
);
expect(screen.getByText("PDF")).toBeInTheDocument();
});
it("should render an image preview when the file is not a PDF", () => {
render(
<FilePreviewCard file={makeFile("photo.png")} previewUrl="blob:http://localhost/abc" onRemove={vi.fn()} />
);
expect(screen.getByAltText("Upload preview")).toBeInTheDocument();
});
it("should not render an image preview when the file is a PDF", () => {
render(
<FilePreviewCard file={makeFile("doc.PDF")} previewUrl={null} onRemove={vi.fn()} />
);
expect(screen.queryByAltText("Upload preview")).not.toBeInTheDocument();
});
it("should call onRemove when the remove button is clicked", async () => {
const onRemove = vi.fn();
const user = userEvent.setup();
render(
<FilePreviewCard file={makeFile("photo.png")} previewUrl={null} onRemove={onRemove} />
);
await user.click(screen.getByRole("button"));
expect(onRemove).toHaveBeenCalledOnce();
});
it("should treat .PDF (uppercase) as a PDF file", () => {
render(
<FilePreviewCard file={makeFile("REPORT.PDF")} previewUrl={null} onRemove={vi.fn()} />
);
expect(screen.getByText("PDF")).toBeInTheDocument();
expect(screen.queryByAltText("Upload preview")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,45 @@
import { DeleteOutlined, FilePdfOutlined } from "@ant-design/icons";
interface FilePreviewCardProps {
file: File;
previewUrl: string | null;
onRemove: () => void;
}
function FilePreviewCard({ file, previewUrl, onRemove }: FilePreviewCardProps) {
const isPdf = file.name.toLowerCase().endsWith(".pdf");
return (
<div className="mb-2">
<div className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg border border-gray-200">
<div className="relative inline-block">
{isPdf ? (
<div className="w-10 h-10 rounded-md bg-red-500 flex items-center justify-center">
<FilePdfOutlined style={{ fontSize: "16px", color: "white" }} />
</div>
) : (
<img
src={previewUrl || ""}
alt="Upload preview"
className="w-10 h-10 rounded-md border border-gray-200 object-cover"
/>
)}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-900 truncate">{file.name}</div>
<div className="text-xs text-gray-500">
{isPdf ? "PDF" : "Image"}
</div>
</div>
<button
className="flex items-center justify-center w-6 h-6 text-gray-400 hover:text-gray-600 hover:bg-gray-200 rounded-full transition-colors"
onClick={onRemove}
>
<DeleteOutlined style={{ fontSize: "12px" }} />
</button>
</div>
</div>
);
}
export default FilePreviewCard;