Merge pull request #25119 from BerriAI/litellm_ryan-march-31

litellm ryan march 31
This commit is contained in:
yuneng-jiang
2026-04-04 10:24:24 -07:00
committed by GitHub
22 changed files with 661 additions and 210 deletions
@@ -201,6 +201,7 @@ router_settings:
| enable_json_schema_validation | boolean | If true, enables json schema validation for all requests. |
| enable_key_alias_format_validation | boolean | If true, validates `key_alias` format on `/key/generate` and `/key/update`. Must be 2-255 chars, start/end with alphanumeric, only allow `a-zA-Z0-9_-/.@`. Default `false`. |
| disable_copilot_system_to_assistant | boolean | **DEPRECATED** - GitHub Copilot API supports system prompts. |
| default_team_params | object | Default parameters applied to every new team created via `/team/new` (including SSO auto-created teams). Only fills in fields not explicitly set in the request. Sub-fields: `max_budget` (float), `budget_duration` (string, e.g. `"30d"`), `tpm_limit` (integer), `rpm_limit` (integer), `team_member_permissions` (array of strings, e.g. `["/team/daily/activity", "/key/generate"]`), `models` (array of strings — only applied to SSO auto-created teams). |
### general_settings - Reference
+17 -8
View File
@@ -358,10 +358,15 @@ When you connect litellm to your SSO provider, litellm can auto-create teams. Us
```yaml showLineNumbers title="Default Params for new teams"
litellm_settings:
default_team_params: # Default Params to apply when litellm auto creates a team from SSO IDP provider
max_budget: 100 # Optional[float], optional): $100 budget for the team
budget_duration: 30d # Optional[str], optional): 30 days budget_duration for the team
models: ["gpt-3.5-turbo"] # Optional[List[str]], optional): models to be used by the team
default_team_params: # Applied to all /team/new calls (including SSO auto-created teams) when the field is not explicitly set
max_budget: 100 # Optional[float]: $100 budget for the team
budget_duration: 30d # Optional[str]: 30 days budget_duration for the team
models: ["gpt-3.5-turbo"] # Optional[List[str]]: models for the team (only applied to SSO auto-created teams)
tpm_limit: 100000 # Optional[int]: tokens per minute limit
rpm_limit: 1000 # Optional[int]: requests per minute limit
team_member_permissions: # Optional[List[str]]: permissions granted to non-admin team members
- "/team/daily/activity" # Allow members to view team usage
- "/key/generate" # Allow members to generate API keys
```
@@ -390,10 +395,14 @@ litellm_settings:
max_budget_in_team: 100 # Optional[float], optional): $100 budget for the team. Defaults to None.
user_role: "user" # Optional[str], optional): "user" or "admin". Defaults to "user"
default_team_params: # Default Params to apply when litellm auto creates a team from SSO IDP provider
max_budget: 100 # Optional[float], optional): $100 budget for the team
budget_duration: 30d # Optional[str], optional): 30 days budget_duration for the team
models: ["gpt-3.5-turbo"] # Optional[List[str]], optional): models to be used by the team
default_team_params: # Applied to all /team/new calls (including SSO auto-created teams) when the field is not explicitly set
max_budget: 100 # Optional[float]: $100 budget for the team
budget_duration: 30d # Optional[str]: 30 days budget_duration for the team
models: ["gpt-3.5-turbo"] # Optional[List[str]]: models for the team (only applied to SSO auto-created teams)
tpm_limit: 100000 # Optional[int]: tokens per minute limit
rpm_limit: 1000 # Optional[int]: requests per minute limit
team_member_permissions: # Optional[List[str]]: permissions granted to non-admin team members
- "/team/daily/activity"
upperbound_key_generate_params: # Upperbound for /key/generate requests when self-serve flow is on
+6 -4
View File
@@ -123,10 +123,12 @@ Navigate to your litellm config file and set the following params
```yaml showLineNumbers title="litellm config with default_team_params"
litellm_settings:
default_team_params: # Default Params to apply when litellm auto creates a team from SSO IDP provider
max_budget: 100 # Optional[float], optional): $100 budget for the team
budget_duration: 30d # Optional[str], optional): 30 days budget_duration for the team
models: ["gpt-3.5-turbo"] # Optional[List[str]], optional): models to be used by the team
default_team_params: # Applied to all /team/new calls (including SSO auto-created teams) when the field is not explicitly set
max_budget: 100 # Optional[float]: $100 budget for the team
budget_duration: 30d # Optional[str]: 30 days budget_duration for the team
models: ["gpt-3.5-turbo"] # Optional[List[str]]: models for the team (only applied to SSO auto-created teams)
team_member_permissions: # Optional[List[str]]: permissions granted to non-admin team members
- "/team/daily/activity" # Allow members to view team usage
```
### 3.2 Auto-create a new team on LiteLLM
+4
View File
@@ -3809,6 +3809,10 @@ class OrganizationMemberUpdateResponse(MemberUpdateResponse):
class TeamInfoResponseObjectTeamTable(LiteLLM_TeamTable):
team_member_budget_table: Optional[LiteLLM_BudgetTable] = None
# Resources inherited from access groups (separate from direct assignments)
access_group_models: Optional[List[str]] = None
access_group_mcp_server_ids: Optional[List[str]] = None
access_group_agent_ids: Optional[List[str]] = None
class TeamInfoResponseObject(TypedDict):
@@ -4382,6 +4382,42 @@ async def list_keys(
)
async def _apply_non_admin_alias_scope(
user_api_key_dict: UserAPIKeyAuth,
prisma_client: Any,
query_params: List[Any],
where_parts: List[str],
) -> None:
"""Append SQL scope conditions so non-admin users only see aliases for
keys they own or keys belonging to teams they are members of."""
scope_conditions: List[str] = []
if user_api_key_dict.user_id:
query_params.append(user_api_key_dict.user_id)
scope_conditions.append(f"user_id = ${len(query_params)}")
# Look up the user's teams from the user table
user_teams: List[str] = []
if user_api_key_dict.user_id:
user_row = await prisma_client.db.litellm_usertable.find_unique(
where={"user_id": user_api_key_dict.user_id}
)
if user_row is not None:
user_teams = getattr(user_row, "teams", []) or []
if user_teams:
team_placeholders = ", ".join(
f"${len(query_params) + i + 1}" for i in range(len(user_teams))
)
query_params.extend(user_teams)
scope_conditions.append(f"team_id IN ({team_placeholders})")
if scope_conditions:
where_parts.append(f"({' OR '.join(scope_conditions)})")
else:
# No user_id and no teams — return nothing
where_parts.append("FALSE")
@router.get(
"/key/aliases",
tags=["key management"],
@@ -4395,6 +4431,9 @@ async def key_aliases(
search: Optional[str] = Query(
None, description="Search key aliases (case-insensitive partial match)"
),
team_id: Optional[str] = Query(
None, description="Filter aliases to keys belonging to this team"
),
) -> Dict[str, Any]:
"""
Lists key aliases with pagination and optional search.
@@ -4439,37 +4478,18 @@ async def key_aliases(
LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value,
]
if not is_proxy_admin:
scope_conditions: List[str] = []
if user_api_key_dict.user_id:
query_params.append(user_api_key_dict.user_id)
scope_conditions.append(f"user_id = ${len(query_params)}")
# Look up the user's teams from the user table
user_teams: List[str] = []
if user_api_key_dict.user_id:
user_row = await prisma_client.db.litellm_usertable.find_unique(
where={"user_id": user_api_key_dict.user_id}
)
if user_row is not None:
user_teams = getattr(user_row, "teams", []) or []
if user_teams:
team_placeholders = ", ".join(
f"${len(query_params) + i + 1}" for i in range(len(user_teams))
)
query_params.extend(user_teams)
scope_conditions.append(f"team_id IN ({team_placeholders})")
if scope_conditions:
where_parts.append(f"({' OR '.join(scope_conditions)})")
else:
# No user_id and no teams — return nothing
where_parts.append("FALSE")
await _apply_non_admin_alias_scope(
user_api_key_dict, prisma_client, query_params, where_parts
)
if search:
query_params.append(f"%{search}%")
where_parts.append(f"key_alias ILIKE ${len(query_params)}")
if team_id:
query_params.append(team_id)
where_parts.append(f"team_id = ${len(query_params)}")
where_sql = " AND ".join(where_parts)
count_sql = f'SELECT COUNT(*) AS count FROM "LiteLLM_VerificationToken" WHERE {where_sql}'
@@ -2932,6 +2932,25 @@ async def _add_team_member_budget_table(
return team_info_response_object
async def _resolve_team_access_group_resources(_team_info: Any) -> None:
"""Populate access_group_models / mcp_server_ids / agent_ids on the team
info response by resolving inherited resources from its access groups."""
if not _team_info.access_group_ids:
return
ag_lookup = await _batch_resolve_access_group_resources(
_team_info.access_group_ids
)
models, mcp_ids, agent_ids = set(), set(), set()
for ag_id in _team_info.access_group_ids:
if ag_id in ag_lookup:
models.update(ag_lookup[ag_id]["models"])
mcp_ids.update(ag_lookup[ag_id]["mcp_server_ids"])
agent_ids.update(ag_lookup[ag_id]["agent_ids"])
_team_info.access_group_models = list(models)
_team_info.access_group_mcp_server_ids = list(mcp_ids)
_team_info.access_group_agent_ids = list(agent_ids)
@router.get(
"/team/info", tags=["team management"], dependencies=[Depends(user_api_key_auth)]
)
@@ -3042,6 +3061,9 @@ async def team_info(
team_info_response_object=_team_info,
)
# Resolve resources inherited from access groups
await _resolve_team_access_group_resources(_team_info)
response_object = TeamInfoResponseObject(
team_id=team_id,
team_info=_team_info,
@@ -3332,6 +3354,36 @@ async def _build_team_list_where_conditions(
return where_conditions
async def _batch_resolve_access_group_resources(
all_access_group_ids: List[str],
) -> Dict[str, Dict[str, List[str]]]:
"""
Batch-fetch access groups in a single DB query and return a per-group
resource map.
Returns {ag_id: {"models": [...], "mcp_server_ids": [...], "agent_ids": [...]}}.
Missing/invalid groups are silently omitted.
"""
from litellm.proxy.proxy_server import prisma_client as _prisma_client
if not all_access_group_ids or _prisma_client is None:
return {}
unique_ids = list(set(all_access_group_ids))
rows = await _prisma_client.db.litellm_accessgrouptable.find_many(
where={"access_group_id": {"in": unique_ids}},
)
result: Dict[str, Dict[str, List[str]]] = {}
for row in rows:
result[row.access_group_id] = {
"models": list(row.access_model_names or []),
"mcp_server_ids": list(row.access_mcp_server_ids or []),
"agent_ids": list(row.access_agent_ids or []),
}
return result
def _convert_teams_to_response_models(
teams: list,
use_deleted_table: bool,
@@ -3358,6 +3410,73 @@ def _convert_teams_to_response_models(
return team_list
async def _enforce_list_team_v2_access(
user_api_key_dict: UserAPIKeyAuth,
user_id: Optional[str],
organization_id: Optional[str],
prisma_client: Any,
user_api_key_cache: Any,
proxy_logging_obj: Any,
) -> Tuple[Optional[str], Optional[List[str]]]:
"""Enforce access control for list_team_v2.
- 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.
Returns the (possibly overridden) user_id and org_admin_org_ids.
"""
is_proxy_admin = _user_has_admin_view(user_api_key_dict)
org_admin_org_ids: Optional[List[str]] = None
if is_proxy_admin:
return user_id, org_admin_org_ids
# 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
return user_id, org_admin_org_ids
@router.get(
"/v2/team/list",
tags=["team management"],
@@ -3435,54 +3554,14 @@ async def list_team_v2(
)
# --- 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 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
user_id, org_admin_org_ids = await _enforce_list_team_v2_access(
user_api_key_dict=user_api_key_dict,
user_id=user_id,
organization_id=organization_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
)
if status is not None and status != "deleted":
raise HTTPException(
@@ -3558,6 +3637,30 @@ async def list_team_v2(
# Convert Prisma models to response models with members_count
team_list = _convert_teams_to_response_models(teams, use_deleted_table)
# Resolve resources inherited from access groups (single batch query)
if not use_deleted_table:
team_items_with_ag = [
t for t in team_list
if isinstance(t, TeamListItem) and t.access_group_ids
]
if team_items_with_ag:
all_ag_ids = [
ag_id
for t in team_items_with_ag
for ag_id in (t.access_group_ids or [])
]
ag_lookup = await _batch_resolve_access_group_resources(all_ag_ids)
for team_item in team_items_with_ag:
models, mcp_ids, agent_ids = set(), set(), set()
for ag_id in (team_item.access_group_ids or []):
if ag_id in ag_lookup:
models.update(ag_lookup[ag_id]["models"])
mcp_ids.update(ag_lookup[ag_id]["mcp_server_ids"])
agent_ids.update(ag_lookup[ag_id]["agent_ids"])
team_item.access_group_models = list(models)
team_item.access_group_mcp_server_ids = list(mcp_ids)
team_item.access_group_agent_ids = list(agent_ids)
return {
"teams": team_list,
"total": total_count,
@@ -47,6 +47,10 @@ class TeamListItem(LiteLLM_TeamTable):
"""A team item in the paginated list response, enriched with computed fields."""
members_count: int = 0
# Resources inherited from access groups (separate from direct assignments)
access_group_models: Optional[List[str]] = None
access_group_mcp_server_ids: Optional[List[str]] = None
access_group_agent_ids: Optional[List[str]] = None
class TeamListResponse(BaseModel):
Generated
+41 -32
View File
@@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand.
# This file is automatically @generated by Poetry 2.3.1 and should not be changed by hand.
[[package]]
name = "a2a-sdk"
@@ -7,11 +7,11 @@ description = "A2A Python SDK"
optional = false
python-versions = ">=3.10"
groups = ["main", "proxy-dev"]
markers = "python_version >= \"3.10\""
files = [
{file = "a2a_sdk-0.3.25-py3-none-any.whl", hash = "sha256:2fce38faea82eb0b6f9f9c2bcf761b0d78612c80ef0e599b50d566db1b2654b5"},
{file = "a2a_sdk-0.3.25.tar.gz", hash = "sha256:afda85bab8d6af0c5d15e82f326c94190f6be8a901ce562d045a338b7127242f"},
]
markers = {main = "python_version >= \"3.10\" and extra == \"extra-proxy\"", proxy-dev = "python_version >= \"3.10\""}
[package.dependencies]
google-api-core = ">=1.26.0"
@@ -386,6 +386,7 @@ files = [
{file = "azure_core-1.36.0-py3-none-any.whl", hash = "sha256:fee9923a3a753e94a259563429f3644aaf05c486d45b1215d098115102d91d3b"},
{file = "azure_core-1.36.0.tar.gz", hash = "sha256:22e5605e6d0bf1d229726af56d9e92bc37b6e726b141a18be0b4d424131741b7"},
]
markers = {main = "extra == \"proxy\" or extra == \"extra-proxy\""}
[package.dependencies]
requests = ">=2.21.0"
@@ -406,6 +407,7 @@ files = [
{file = "azure_identity-1.25.3-py3-none-any.whl", hash = "sha256:f4d0b956a8146f30333e071374171f3cfa7bdb8073adb8c3814b65567aa7447c"},
{file = "azure_identity-1.25.3.tar.gz", hash = "sha256:ab23c0d63015f50b630ef6c6cf395e7262f439ce06e5d07a64e874c724f8d9e6"},
]
markers = {main = "extra == \"proxy\" or extra == \"extra-proxy\""}
[package.dependencies]
azure-core = ">=1.31.0"
@@ -719,7 +721,7 @@ files = [
{file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"},
{file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"},
]
markers = {main = "platform_python_implementation != \"PyPy\" or extra == \"proxy\"", dev = "platform_python_implementation != \"PyPy\"", proxy-dev = "platform_python_implementation != \"PyPy\""}
markers = {main = "(platform_python_implementation != \"PyPy\" or extra == \"proxy\") and (python_version >= \"3.10\" or extra == \"proxy\" or extra == \"extra-proxy\" or extra == \"google\") and (extra == \"proxy\" or extra == \"extra-proxy\" or extra == \"google\" or extra == \"mlflow\")", dev = "platform_python_implementation != \"PyPy\"", proxy-dev = "platform_python_implementation != \"PyPy\""}
[package.dependencies]
pycparser = {version = "*", markers = "implementation_name != \"PyPy\""}
@@ -1313,6 +1315,7 @@ files = [
{file = "cryptography-43.0.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2ce6fae5bdad59577b44e4dfed356944fbf1d925269114c28be377692643b4ff"},
{file = "cryptography-43.0.3.tar.gz", hash = "sha256:315b9001266a492a6ff443b61238f956b214dbec9910a081ba5b6646a055a805"},
]
markers = {main = "python_version >= \"3.10\" and (extra == \"proxy\" or extra == \"extra-proxy\" or extra == \"google\" or extra == \"mlflow\") or extra == \"proxy\" or extra == \"extra-proxy\" or extra == \"google\""}
[package.dependencies]
cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""}
@@ -1378,7 +1381,7 @@ files = [
{file = "deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f"},
{file = "deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223"},
]
markers = {main = "python_version >= \"3.10\""}
markers = {main = "python_version >= \"3.10\" and extra == \"mlflow\""}
[package.dependencies]
wrapt = ">=1.10,<3"
@@ -2114,11 +2117,11 @@ description = "Google API client core library"
optional = false
python-versions = ">=3.7"
groups = ["main", "proxy-dev"]
markers = "python_version >= \"3.14\""
files = [
{file = "google_api_core-2.25.2-py3-none-any.whl", hash = "sha256:e9a8f62d363dc8424a8497f4c2a47d6bcda6c16514c935629c257ab5d10210e7"},
{file = "google_api_core-2.25.2.tar.gz", hash = "sha256:1c63aa6af0d0d5e37966f157a77f9396d820fba59f9e43e9415bc3dc5baff300"},
]
markers = {main = "python_version >= \"3.14\" and (extra == \"extra-proxy\" or extra == \"google\")", proxy-dev = "python_version >= \"3.14\""}
[package.dependencies]
google-auth = ">=2.14.1,<3.0.0"
@@ -2146,7 +2149,7 @@ files = [
{file = "google_api_core-2.28.1-py3-none-any.whl", hash = "sha256:4021b0f8ceb77a6fb4de6fde4502cecab45062e66ff4f2895169e0b35bc9466c"},
{file = "google_api_core-2.28.1.tar.gz", hash = "sha256:2b405df02d68e68ce0fbc138559e6036559e685159d148ae5861013dc201baf8"},
]
markers = {main = "(python_version >= \"3.10\" or extra == \"google\" or extra == \"extra-proxy\") and python_version <= \"3.13\"", proxy-dev = "python_version >= \"3.10\" and python_version <= \"3.13\""}
markers = {main = "python_version <= \"3.13\" and (extra == \"extra-proxy\" or extra == \"google\")", proxy-dev = "python_version >= \"3.10\" and python_version <= \"3.13\""}
[package.dependencies]
google-auth = ">=2.14.1,<3.0.0"
@@ -2183,7 +2186,7 @@ files = [
{file = "google_auth-2.49.1-py3-none-any.whl", hash = "sha256:195ebe3dca18eddd1b3db5edc5189b76c13e96f29e73043b923ebcf3f1a860f7"},
{file = "google_auth-2.49.1.tar.gz", hash = "sha256:16d40da1c3c5a0533f57d268fe72e0ebb0ae1cc3b567024122651c045d879b64"},
]
markers = {main = "extra == \"google\" or extra == \"extra-proxy\" or python_version >= \"3.10\"", proxy-dev = "python_version >= \"3.10\""}
markers = {main = "python_version >= \"3.10\" and (extra == \"extra-proxy\" or extra == \"google\" or extra == \"mlflow\") or extra == \"google\" or extra == \"extra-proxy\"", proxy-dev = "python_version >= \"3.10\""}
[package.dependencies]
cryptography = ">=38.0.3"
@@ -2351,11 +2354,11 @@ files = [
]
[package.dependencies]
google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0dev", extras = ["grpc"]}
google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0dev"
grpc-google-iam-v1 = ">=0.12.4,<1.0.0dev"
proto-plus = ">=1.22.3,<2.0.0dev"
protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0dev"
google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0.dev0", extras = ["grpc"]}
google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0.dev0"
grpc-google-iam-v1 = ">=0.12.4,<1.0.0.dev0"
proto-plus = ">=1.22.3,<2.0.0.dev0"
protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0.dev0"
[[package]]
name = "google-cloud-resource-manager"
@@ -2537,7 +2540,7 @@ files = [
{file = "googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038"},
{file = "googleapis_common_protos-1.72.0.tar.gz", hash = "sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5"},
]
markers = {main = "extra == \"google\" or extra == \"extra-proxy\" or python_version >= \"3.10\""}
markers = {main = "python_version >= \"3.10\" and (extra == \"extra-proxy\" or extra == \"google\") or extra == \"google\" or extra == \"extra-proxy\""}
[package.dependencies]
grpcio = {version = ">=1.44.0,<2.0.0", optional = true, markers = "extra == \"grpc\""}
@@ -2946,11 +2949,11 @@ description = "Consume Server-Sent Event (SSE) messages with HTTPX."
optional = false
python-versions = ">=3.9"
groups = ["main", "proxy-dev"]
markers = "python_version >= \"3.10\""
files = [
{file = "httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc"},
{file = "httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d"},
]
markers = {main = "python_version >= \"3.10\" and (extra == \"proxy\" or extra == \"extra-proxy\")", proxy-dev = "python_version >= \"3.10\""}
[[package]]
name = "huey"
@@ -3321,7 +3324,7 @@ files = [
[package.dependencies]
attrs = ">=22.2.0"
jsonschema-specifications = ">=2023.03.6"
jsonschema-specifications = ">=2023.3.6"
referencing = ">=0.28.4"
rpds-py = ">=0.7.1"
@@ -3598,15 +3601,15 @@ files = [
[[package]]
name = "litellm-proxy-extras"
version = "0.4.63"
version = "0.4.64"
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.63-py3-none-any.whl", hash = "sha256:46ec50083832b6b5ead86e53003657e1a53dc27bd95cbdbaee9c1343726e3acb"},
{file = "litellm_proxy_extras-0.4.63.tar.gz", hash = "sha256:7161b27c3b38a840c13bb113b733196efafe2bb4d2ba22c9bd6e359c4b753aa2"},
{file = "litellm_proxy_extras-0.4.64-py3-none-any.whl", hash = "sha256:e10f1d4bbfa84ce709e5ef559c8345d5bbe56e206655a5328384af87a078be6d"},
{file = "litellm_proxy_extras-0.4.64.tar.gz", hash = "sha256:cca35fd41fea914dc067641df14937ba9037fa50177f11351f8c61902a434e92"},
]
[[package]]
@@ -4095,6 +4098,7 @@ files = [
{file = "msal-1.35.1-py3-none-any.whl", hash = "sha256:8f4e82f34b10c19e326ec69f44dc6b30171f2f7098f3720ea8a9f0c11832caa3"},
{file = "msal-1.35.1.tar.gz", hash = "sha256:70cac18ab80a053bff86219ba64cfe3da1f307c74b009e2da57ef040eb1b5656"},
]
markers = {main = "extra == \"proxy\" or extra == \"extra-proxy\""}
[package.dependencies]
cryptography = ">=2.5,<49"
@@ -4115,6 +4119,7 @@ files = [
{file = "msal_extensions-1.3.1-py3-none-any.whl", hash = "sha256:96d3de4d034504e969ac5e85bae8106c8373b5c6568e4c8fa7af2eca9dbe6bca"},
{file = "msal_extensions-1.3.1.tar.gz", hash = "sha256:c5b0fd10f65ef62b5f1d62f4251d51cbcaf003fcedae8c91b040a488614be1a4"},
]
markers = {main = "extra == \"proxy\" or extra == \"extra-proxy\""}
[package.dependencies]
msal = ">=1.29,<2"
@@ -4366,6 +4371,7 @@ files = [
{file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"},
{file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"},
]
markers = {main = "extra == \"extra-proxy\""}
[[package]]
name = "numpy"
@@ -4494,7 +4500,7 @@ files = [
{file = "opentelemetry_api-1.28.0-py3-none-any.whl", hash = "sha256:8457cd2c59ea1bd0988560f021656cecd254ad7ef6be4ba09dbefeca2409ce52"},
{file = "opentelemetry_api-1.28.0.tar.gz", hash = "sha256:578610bcb8aa5cdcb11169d136cc752958548fb6ccffb0969c1036b0ee9e5353"},
]
markers = {main = "python_version >= \"3.10\""}
markers = {main = "python_version >= \"3.10\" and extra == \"mlflow\""}
[package.dependencies]
deprecated = ">=1.2.6"
@@ -4600,7 +4606,7 @@ files = [
{file = "opentelemetry_sdk-1.28.0-py3-none-any.whl", hash = "sha256:4b37da81d7fad67f6683c4420288c97f4ed0d988845d5886435f428ec4b8429a"},
{file = "opentelemetry_sdk-1.28.0.tar.gz", hash = "sha256:41d5420b2e3fb7716ff4981b510d551eff1fc60eb5a95cf7335b31166812a893"},
]
markers = {main = "python_version >= \"3.10\""}
markers = {main = "python_version >= \"3.10\" and extra == \"mlflow\""}
[package.dependencies]
opentelemetry-api = "1.28.0"
@@ -4618,7 +4624,7 @@ files = [
{file = "opentelemetry_semantic_conventions-0.49b0-py3-none-any.whl", hash = "sha256:0458117f6ead0b12e3221813e3e511d85698c31901cac84682052adb9c17c7cd"},
{file = "opentelemetry_semantic_conventions-0.49b0.tar.gz", hash = "sha256:dbc7b28339e5390b6b28e022835f9bac4e134a80ebf640848306d3c5192557e8"},
]
markers = {main = "python_version >= \"3.10\""}
markers = {main = "python_version >= \"3.10\" and extra == \"mlflow\""}
[package.dependencies]
deprecated = ">=1.2.6"
@@ -5130,6 +5136,7 @@ files = [
{file = "prisma-0.11.0-py3-none-any.whl", hash = "sha256:22bb869e59a2968b99f3483bb417717273ffbc569fd1e9ceed95e5614cbaf53a"},
{file = "prisma-0.11.0.tar.gz", hash = "sha256:3f2f2fd2361e1ec5ff655f2a04c7860c2f2a5bc4c91f78ca9c5c6349735bf693"},
]
markers = {main = "extra == \"extra-proxy\""}
[package.dependencies]
click = ">=7.1.2"
@@ -5303,7 +5310,7 @@ files = [
{file = "proto_plus-1.26.1-py3-none-any.whl", hash = "sha256:13285478c2dcf2abb829db158e1047e2f1e8d63a077d94263c2b88b043c75a66"},
{file = "proto_plus-1.26.1.tar.gz", hash = "sha256:21a515a4c4c0088a773899e23c7bbade3d18f9c66c73edd4c7ee3816bc96a012"},
]
markers = {main = "extra == \"google\" or extra == \"extra-proxy\" or python_version >= \"3.10\"", proxy-dev = "python_version >= \"3.10\""}
markers = {main = "python_version < \"3.13\" and (extra == \"extra-proxy\" or extra == \"google\") or extra == \"google\" or extra == \"extra-proxy\"", proxy-dev = "python_version >= \"3.10\""}
[package.dependencies]
protobuf = ">=3.19.0,<7.0.0"
@@ -5331,7 +5338,7 @@ files = [
{file = "protobuf-5.29.5-py3-none-any.whl", hash = "sha256:6cf42630262c59b2d8de33954443d94b746c952b01434fc58a417fdbd2e84bd5"},
{file = "protobuf-5.29.5.tar.gz", hash = "sha256:bc1463bafd4b0929216c35f437a8e28731a2b7fe3d98bb77a600efced5a15c84"},
]
markers = {main = "extra == \"google\" or extra == \"extra-proxy\" or python_version >= \"3.10\""}
markers = {main = "python_version >= \"3.10\" and (extra == \"extra-proxy\" or extra == \"google\" or extra == \"mlflow\") or extra == \"google\" or extra == \"extra-proxy\""}
[[package]]
name = "psutil"
@@ -5491,7 +5498,7 @@ files = [
{file = "pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629"},
{file = "pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034"},
]
markers = {main = "extra == \"google\" or extra == \"extra-proxy\" or python_version >= \"3.10\"", proxy-dev = "python_version >= \"3.10\""}
markers = {main = "python_version >= \"3.10\" and (extra == \"extra-proxy\" or extra == \"google\" or extra == \"mlflow\") or extra == \"google\" or extra == \"extra-proxy\"", proxy-dev = "python_version >= \"3.10\""}
[[package]]
name = "pyasn1-modules"
@@ -5504,7 +5511,7 @@ files = [
{file = "pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a"},
{file = "pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6"},
]
markers = {main = "extra == \"google\" or extra == \"extra-proxy\" or python_version >= \"3.10\"", proxy-dev = "python_version >= \"3.10\""}
markers = {main = "python_version >= \"3.10\" and (extra == \"extra-proxy\" or extra == \"google\" or extra == \"mlflow\") or extra == \"google\" or extra == \"extra-proxy\"", proxy-dev = "python_version >= \"3.10\""}
[package.dependencies]
pyasn1 = ">=0.6.1,<0.7.0"
@@ -5532,7 +5539,7 @@ files = [
{file = "pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"},
{file = "pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"},
]
markers = {main = "(platform_python_implementation != \"PyPy\" or extra == \"proxy\") and implementation_name != \"PyPy\"", dev = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"", proxy-dev = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\""}
markers = {main = "(platform_python_implementation != \"PyPy\" or extra == \"proxy\") and implementation_name != \"PyPy\" and (python_version >= \"3.10\" or extra == \"proxy\" or extra == \"extra-proxy\" or extra == \"google\") and (extra == \"proxy\" or extra == \"extra-proxy\" or extra == \"google\" or extra == \"mlflow\")", dev = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"", proxy-dev = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\""}
[[package]]
name = "pydantic"
@@ -5755,6 +5762,7 @@ files = [
{file = "pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c"},
{file = "pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b"},
]
markers = {main = "extra == \"extra-proxy\" or extra == \"proxy\""}
[package.dependencies]
cryptography = {version = ">=3.4.0", optional = true, markers = "extra == \"crypto\""}
@@ -6736,10 +6744,10 @@ files = [
]
[package.dependencies]
botocore = ">=1.37.4,<2.0a.0"
botocore = ">=1.37.4,<2.0a0"
[package.extras]
crt = ["botocore[crt] (>=1.37.4,<2.0a.0)"]
crt = ["botocore[crt] (>=1.37.4,<2.0a0)"]
[[package]]
name = "scikit-learn"
@@ -6892,9 +6900,9 @@ tornado = ">=6.4.2,<7"
urllib3 = ">=1.26,<3"
[package.extras]
all = ["boto3 (>=1.34.98,<2)", "botocore (>=1.34.110,<2)", "cohere (>=5.9.4,<6.00)", "dagger-io (>=0.1.1) ; python_version >= \"3.11\"", "fastembed (>=0.3.0,<0.4) ; python_version < \"3.13\"", "google-cloud-aiplatform (>=1.45.0,<2)", "ipykernel (>=6.25.0,<7)", "llama-cpp-python (>=0.2.28,<0.2.86) ; python_version < \"3.13\"", "mistralai (>=0.0.12,<0.1.0)", "mypy (>=1.7.1,<2)", "ollama (>=0.1.7)", "pillow (>=10.2.0,<11.0.0) ; python_version < \"3.13\"", "pinecone[asyncio] (>=7.0.0,<8.0.0)", "psycopg[binary] (>=3.1.0,<4)", "pytest (>=8.2,<9.0)", "pytest-asyncio (>=0.24.0,<0.25)", "pytest-cov (>=4.1.0,<5)", "pytest-mock (>=3.12.0,<4)", "pytest-timeout", "pytest-xdist (>=3.5.0,<4)", "python-dotenv (>=1.0.0,<2)", "qdrant-client (>=1.11.1,<2)", "requests-mock (>=1.12.1,<2)", "ruff (>=0.11.2,<0.12)", "sentence-transformers (>=5.0.0) ; python_version < \"3.13\"", "tokenizers (>=0.19) ; python_version < \"3.13\"", "torch (>=2.6.0) ; python_version < \"3.13\"", "torchvision (>=0.17.0) ; python_version < \"3.13\"", "transformers (>=4.36.2) ; python_version < \"3.13\"", "types-pyyaml (>=6.0.12.12,<7)", "types-requests (>=2.31.0,<3)"]
all = ["boto3 (>=1.34.98,<2)", "botocore (>=1.34.110,<2)", "cohere (>=5.9.4,<6.0)", "dagger-io (>=0.1.1) ; python_version >= \"3.11\"", "fastembed (>=0.3.0,<0.4) ; python_version < \"3.13\"", "google-cloud-aiplatform (>=1.45.0,<2)", "ipykernel (>=6.25.0,<7)", "llama-cpp-python (>=0.2.28,<0.2.86) ; python_version < \"3.13\"", "mistralai (>=0.0.12,<0.1.0)", "mypy (>=1.7.1,<2)", "ollama (>=0.1.7)", "pillow (>=10.2.0,<11.0.0) ; python_version < \"3.13\"", "pinecone[asyncio] (>=7.0.0,<8.0.0)", "psycopg[binary] (>=3.1.0,<4)", "pytest (>=8.2,<9.0)", "pytest-asyncio (>=0.24.0,<0.25)", "pytest-cov (>=4.1.0,<5)", "pytest-mock (>=3.12.0,<4)", "pytest-timeout", "pytest-xdist (>=3.5.0,<4)", "python-dotenv (>=1.0.0,<2)", "qdrant-client (>=1.11.1,<2)", "requests-mock (>=1.12.1,<2)", "ruff (>=0.11.2,<0.12)", "sentence-transformers (>=5.0.0) ; python_version < \"3.13\"", "tokenizers (>=0.19) ; python_version < \"3.13\"", "torch (>=2.6.0) ; python_version < \"3.13\"", "torchvision (>=0.17.0) ; python_version < \"3.13\"", "transformers (>=4.36.2) ; python_version < \"3.13\"", "types-pyyaml (>=6.0.12.12,<7)", "types-requests (>=2.31.0,<3)"]
bedrock = ["boto3 (>=1.34.98,<2)", "botocore (>=1.34.110,<2)"]
cohere = ["cohere (>=5.9.4,<6.00)"]
cohere = ["cohere (>=5.9.4,<6.0)"]
dev = ["dagger-io (>=0.1.1) ; python_version >= \"3.11\"", "ipykernel (>=6.25.0,<7)", "mypy (>=1.7.1,<2)", "pytest (>=8.2,<9.0)", "pytest-asyncio (>=0.24.0,<0.25)", "pytest-cov (>=4.1.0,<5)", "pytest-mock (>=3.12.0,<4)", "pytest-timeout", "pytest-xdist (>=3.5.0,<4)", "python-dotenv (>=1.0.0,<2)", "requests-mock (>=1.12.1,<2)", "ruff (>=0.11.2,<0.12)", "types-pyyaml (>=6.0.12.12,<7)", "types-requests (>=2.31.0,<3)"]
docs = ["pydoc-markdown (>=4.8.2) ; python_version < \"3.12\""]
fastembed = ["fastembed (>=0.3.0,<0.4) ; python_version < \"3.13\""]
@@ -7573,6 +7581,7 @@ files = [
{file = "tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0"},
{file = "tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1"},
]
markers = {main = "extra == \"extra-proxy\""}
[[package]]
name = "tornado"
@@ -8114,7 +8123,7 @@ files = [
{file = "wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22"},
{file = "wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0"},
]
markers = {main = "python_version >= \"3.10\""}
markers = {main = "python_version >= \"3.10\" and extra == \"mlflow\""}
[[package]]
name = "wsproto"
@@ -8309,4 +8318,4 @@ utils = ["numpydoc"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.9,<4.0"
content-hash = "9a2476d5f234f3ce45f399a77fb9e86bd0025e27e2bb905b0ecac7848c4a758c"
content-hash = "4964cafa67fee48aa1c7dd38ca08de20677b273d2b6faee2d6a264330f9099aa"
@@ -6497,3 +6497,131 @@ async def test_create_team_member_budget_table_with_duration():
assert budget_request.budget_duration == "30d"
assert budget_request.max_budget == 20.0
assert result["metadata"]["team_member_budget_id"] == "budget-abc"
# ---------------------------------------------------------------------------
# Tests for _batch_resolve_access_group_resources
# ---------------------------------------------------------------------------
class TestBatchResolveAccessGroupResources:
"""Tests for the batch access group resource resolution helper."""
@pytest.mark.asyncio
async def test_returns_empty_when_no_ids(self):
"""Empty list should return empty dict."""
from litellm.proxy.management_endpoints.team_endpoints import (
_batch_resolve_access_group_resources,
)
assert await _batch_resolve_access_group_resources([]) == {}
@pytest.mark.asyncio
async def test_single_access_group(self):
"""Single access group should return its resources."""
from litellm.proxy.management_endpoints.team_endpoints import (
_batch_resolve_access_group_resources,
)
fake_row = MagicMock()
fake_row.access_group_id = "ag-1"
fake_row.access_model_names = ["gpt-4", "claude-3"]
fake_row.access_mcp_server_ids = ["mcp-1"]
fake_row.access_agent_ids = ["agent-1", "agent-2"]
fake_prisma = MagicMock()
fake_prisma.db.litellm_accessgrouptable.find_many = AsyncMock(return_value=[fake_row])
with patch("litellm.proxy.proxy_server.prisma_client", fake_prisma):
result = await _batch_resolve_access_group_resources(["ag-1"])
assert sorted(result["ag-1"]["models"]) == ["claude-3", "gpt-4"]
assert result["ag-1"]["mcp_server_ids"] == ["mcp-1"]
assert sorted(result["ag-1"]["agent_ids"]) == ["agent-1", "agent-2"]
@pytest.mark.asyncio
async def test_multiple_access_groups(self):
"""Multiple access groups returned in a single query."""
from litellm.proxy.management_endpoints.team_endpoints import (
_batch_resolve_access_group_resources,
)
row1 = MagicMock()
row1.access_group_id = "ag-1"
row1.access_model_names = ["gpt-4"]
row1.access_mcp_server_ids = ["mcp-1"]
row1.access_agent_ids = ["agent-1"]
row2 = MagicMock()
row2.access_group_id = "ag-2"
row2.access_model_names = ["gemini"]
row2.access_mcp_server_ids = ["mcp-2"]
row2.access_agent_ids = ["agent-2"]
fake_prisma = MagicMock()
fake_prisma.db.litellm_accessgrouptable.find_many = AsyncMock(return_value=[row1, row2])
with patch("litellm.proxy.proxy_server.prisma_client", fake_prisma):
result = await _batch_resolve_access_group_resources(["ag-1", "ag-2"])
assert result["ag-1"]["models"] == ["gpt-4"]
assert result["ag-2"]["models"] == ["gemini"]
@pytest.mark.asyncio
async def test_missing_access_group_omitted(self):
"""If an access group doesn't exist in DB, it's simply not in the result."""
from litellm.proxy.management_endpoints.team_endpoints import (
_batch_resolve_access_group_resources,
)
row1 = MagicMock()
row1.access_group_id = "ag-1"
row1.access_model_names = ["gpt-4"]
row1.access_mcp_server_ids = []
row1.access_agent_ids = []
fake_prisma = MagicMock()
fake_prisma.db.litellm_accessgrouptable.find_many = AsyncMock(return_value=[row1])
with patch("litellm.proxy.proxy_server.prisma_client", fake_prisma):
result = await _batch_resolve_access_group_resources(["ag-1", "ag-missing"])
assert "ag-1" in result
assert "ag-missing" not in result
@pytest.mark.asyncio
async def test_returns_empty_when_prisma_unavailable(self):
"""If prisma_client is None, should return empty dict."""
from litellm.proxy.management_endpoints.team_endpoints import (
_batch_resolve_access_group_resources,
)
with patch("litellm.proxy.proxy_server.prisma_client", None):
result = await _batch_resolve_access_group_resources(["ag-1"])
assert result == {}
@pytest.mark.asyncio
async def test_deduplicates_input_ids(self):
"""Duplicate IDs in input should result in a single DB lookup."""
from litellm.proxy.management_endpoints.team_endpoints import (
_batch_resolve_access_group_resources,
)
row1 = MagicMock()
row1.access_group_id = "ag-1"
row1.access_model_names = ["gpt-4"]
row1.access_mcp_server_ids = []
row1.access_agent_ids = []
fake_find_many = AsyncMock(return_value=[row1])
fake_prisma = MagicMock()
fake_prisma.db.litellm_accessgrouptable.find_many = fake_find_many
with patch("litellm.proxy.proxy_server.prisma_client", fake_prisma):
result = await _batch_resolve_access_group_resources(["ag-1", "ag-1", "ag-1"])
# Should have been called with deduplicated list
call_args = fake_find_many.call_args
assert len(call_args.kwargs["where"]["access_group_id"]["in"]) == 1
assert "ag-1" in result
@@ -65,7 +65,7 @@ describe("useInfiniteKeyAliases", () => {
expect(result.current.isSuccess).toBe(true);
});
expect(mockKeyAliasesCall).toHaveBeenCalledWith("test-token", 1, 50, undefined);
expect(mockKeyAliasesCall).toHaveBeenCalledWith("test-token", 1, 50, undefined, undefined);
expect(result.current.data?.pages[0]).toEqual(mockPage1);
});
@@ -74,7 +74,7 @@ describe("useInfiniteKeyAliases", () => {
renderHook(() => useInfiniteKeyAliases(25), { wrapper });
await waitFor(() => {
expect(mockKeyAliasesCall).toHaveBeenCalledWith("test-token", 1, 25, undefined);
expect(mockKeyAliasesCall).toHaveBeenCalledWith("test-token", 1, 25, undefined, undefined);
});
});
@@ -83,7 +83,7 @@ describe("useInfiniteKeyAliases", () => {
renderHook(() => useInfiniteKeyAliases(50, "my-alias"), { wrapper });
await waitFor(() => {
expect(mockKeyAliasesCall).toHaveBeenCalledWith("test-token", 1, 50, "my-alias");
expect(mockKeyAliasesCall).toHaveBeenCalledWith("test-token", 1, 50, "my-alias", undefined);
});
});
@@ -145,7 +145,7 @@ describe("useInfiniteKeyAliases", () => {
expect(result.current.data?.pages).toHaveLength(2);
});
expect(mockKeyAliasesCall).toHaveBeenCalledWith("test-token", 2, 2, undefined);
expect(mockKeyAliasesCall).toHaveBeenCalledWith("test-token", 2, 2, undefined, undefined);
expect(result.current.data?.pages[1]).toEqual(mockPage2);
});
@@ -171,7 +171,7 @@ describe("useInfiniteKeyAliases", () => {
rerender({ search: "search-result" });
await waitFor(() => {
expect(mockKeyAliasesCall).toHaveBeenCalledWith("test-token", 1, 50, "search-result");
expect(mockKeyAliasesCall).toHaveBeenCalledWith("test-token", 1, 50, "search-result", undefined);
});
});
});
@@ -8,6 +8,7 @@ const infiniteKeyAliasKeys = createQueryKeys("infiniteKeyAliases");
export const useInfiniteKeyAliases = (
size: number = 50,
search?: string,
team_id?: string,
) => {
const { accessToken } = useAuthorized();
return useInfiniteQuery<PaginatedKeyAliasResponse>({
@@ -15,6 +16,7 @@ export const useInfiniteKeyAliases = (
filters: {
size,
...(search && { search }),
...(team_id && { team_id }),
},
}),
queryFn: async ({ pageParam }) => {
@@ -23,6 +25,7 @@ export const useInfiniteKeyAliases = (
pageParam as number,
size,
search,
team_id,
);
},
initialPageParam: 1,
@@ -125,14 +125,14 @@ describe("ModelsCell", () => {
expect(screen.getByText("+2 more models")).toBeInTheDocument();
});
it("should render 'all-proxy-models' entries in the overflow section as 'All Proxy Models' badges", () => {
it("should collapse to a single 'All Proxy Models' badge when the models list includes 'all-proxy-models'", () => {
renderModelsCell(makeTeam(["m1", "m2", "m3", "all-proxy-models"]));
act(() => {
screen.getByRole("button", { name: /accordion/i }).click();
});
// There should now be an "All Proxy Models" badge in the expanded section
// When all-proxy-models is present, all individual models are hidden and no accordion is shown
expect(screen.getByText("All Proxy Models")).toBeInTheDocument();
expect(screen.queryByText("m1")).not.toBeInTheDocument();
expect(screen.queryByText("m2")).not.toBeInTheDocument();
expect(screen.queryByText("m3")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /accordion/i })).not.toBeInTheDocument();
});
});
@@ -1,16 +1,57 @@
import { Badge, Icon, TableCell, Text } from "@tremor/react";
import { ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/outline";
import { getModelDisplayName } from "@/components/key_team_helpers/fetch_available_models_team_key";
import React, { useState } from "react";
import React, { useMemo, useState } from "react";
import { Team } from "@/components/key_team_helpers/key_list";
interface ModelsCellProps {
team: Team;
}
interface ModelEntry {
name: string;
source: "direct" | "access_group";
}
const ModelsCell = ({ team }: ModelsCellProps) => {
const [expandedAccordion, setExpandedAccordion] = useState<boolean>(false);
const isAllModels = !team.models || team.models.length === 0 || team.models.includes("all-proxy-models");
const modelEntries: ModelEntry[] = useMemo(() => {
if (isAllModels) return [];
const entries: ModelEntry[] = team.models.map((m) => ({
name: m,
source: "direct" as const,
}));
for (const m of team.access_group_models || []) {
entries.push({ name: m, source: "access_group" });
}
return entries;
}, [team.models, team.access_group_models, isAllModels]);
const renderBadge = (entry: ModelEntry, index: number) => {
if (entry.name === "all-proxy-models") {
return (
<Badge key={index} size={"xs"} color="red">
<Text>All Proxy Models</Text>
</Badge>
);
}
const displayName = getModelDisplayName(entry.name);
const truncated = displayName.length > 30 ? `${displayName.slice(0, 30)}...` : displayName;
return (
<Badge
key={index}
size={"xs"}
color={entry.source === "access_group" ? "green" : "blue"}
title={entry.source === "access_group" ? "From access group" : "Direct assignment"}
>
<Text>{truncated}</Text>
</Badge>
);
};
return (
<TableCell
style={{
@@ -18,78 +59,46 @@ const ModelsCell = ({ team }: ModelsCellProps) => {
whiteSpace: "pre-wrap",
overflow: "hidden",
}}
className={team.models.length > 3 ? "px-0" : ""}
className={modelEntries.length > 3 ? "px-0" : ""}
>
<div className="flex flex-col">
{Array.isArray(team.models) ? (
{modelEntries.length === 0 ? (
<Badge size={"xs"} className="mb-1" color="red">
<Text>All Proxy Models</Text>
</Badge>
) : (
<div className="flex flex-col">
{team.models.length === 0 ? (
<Badge size={"xs"} className="mb-1" color="red">
<Text>All Proxy Models</Text>
</Badge>
) : (
<>
<div className="flex items-start">
{team.models.length > 3 && (
<div>
<Icon
icon={expandedAccordion ? ChevronDownIcon : ChevronRightIcon}
className="cursor-pointer"
size="xs"
onClick={() => {
setExpandedAccordion((prev) => !prev);
}}
/>
</div>
)}
<div className="flex flex-wrap gap-1">
{team.models.slice(0, 3).map((model: string, index: number) =>
model === "all-proxy-models" ? (
<Badge key={index} size={"xs"} color="red">
<Text>All Proxy Models</Text>
</Badge>
) : (
<Badge key={index} size={"xs"} color="blue">
<Text>
{model.length > 30
? `${getModelDisplayName(model).slice(0, 30)}...`
: getModelDisplayName(model)}
</Text>
</Badge>
),
)}
{team.models.length > 3 && !expandedAccordion && (
<Badge size={"xs"} color="gray" className="cursor-pointer">
<Text>
+{team.models.length - 3} {team.models.length - 3 === 1 ? "more model" : "more models"}
</Text>
</Badge>
)}
{expandedAccordion && (
<div className="flex flex-wrap gap-1">
{team.models.slice(3).map((model: string, index: number) =>
model === "all-proxy-models" ? (
<Badge key={index + 3} size={"xs"} color="red">
<Text>All Proxy Models</Text>
</Badge>
) : (
<Badge key={index + 3} size={"xs"} color="blue">
<Text>
{model.length > 30
? `${getModelDisplayName(model).slice(0, 30)}...`
: getModelDisplayName(model)}
</Text>
</Badge>
),
)}
</div>
)}
</div>
<div className="flex items-start">
{modelEntries.length > 3 && (
<div>
<Icon
icon={expandedAccordion ? ChevronDownIcon : ChevronRightIcon}
className="cursor-pointer"
size="xs"
onClick={() => {
setExpandedAccordion((prev) => !prev);
}}
/>
</div>
</>
)}
)}
<div className="flex flex-wrap gap-1">
{modelEntries.slice(0, 3).map((entry, index) => renderBadge(entry, index))}
{modelEntries.length > 3 && !expandedAccordion && (
<Badge size={"xs"} color="gray" className="cursor-pointer">
<Text>
+{modelEntries.length - 3} {modelEntries.length - 3 === 1 ? "more model" : "more models"}
</Text>
</Badge>
)}
{expandedAccordion && (
<div className="flex flex-wrap gap-1">
{modelEntries.slice(3).map((entry, index) => renderBadge(entry, index + 3))}
</div>
)}
</div>
</div>
</div>
) : null}
)}
</div>
</TableCell>
);
@@ -112,7 +112,7 @@ describe("PaginatedKeyAliasSelect", () => {
it("should pass pageSize to useInfiniteKeyAliases", () => {
renderWithProviders(<PaginatedKeyAliasSelect onChange={mockOnChange} pageSize={25} />);
expect(mockUseInfiniteKeyAliases).toHaveBeenCalledWith(25, undefined);
expect(mockUseInfiniteKeyAliases).toHaveBeenCalledWith(25, undefined, undefined);
});
it("should pass search to useInfiniteKeyAliases when user types", async () => {
@@ -124,7 +124,7 @@ describe("PaginatedKeyAliasSelect", () => {
await user.keyboard("my-alias");
await waitFor(() => {
expect(mockUseInfiniteKeyAliases).toHaveBeenCalledWith(50, "my-alias");
expect(mockUseInfiniteKeyAliases).toHaveBeenCalledWith(50, "my-alias", undefined);
});
});
@@ -12,6 +12,7 @@ export interface PaginatedKeyAliasSelectProps {
pageSize?: number;
allowClear?: boolean;
disabled?: boolean;
allFilters?: { [key: string]: string };
}
const SCROLL_THRESHOLD = 0.8;
@@ -25,19 +26,22 @@ export const PaginatedKeyAliasSelect = ({
pageSize = 50,
allowClear = true,
disabled = false,
allFilters,
}: PaginatedKeyAliasSelectProps) => {
const [searchInput, setSearchInput] = useState("");
const [debouncedSearch, setDebouncedSearch] = useDebouncedState("", {
wait: DEBOUNCE_MS,
});
const teamId = allFilters?.["Team ID"] || undefined;
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
} = useInfiniteKeyAliases(pageSize, debouncedSearch || undefined);
} = useInfiniteKeyAliases(pageSize, debouncedSearch || undefined, teamId);
const options = useMemo(() => {
if (!data?.pages) return [];
@@ -45,6 +45,10 @@ vi.mock("../../../EntityUsageExport", () => ({
UsageExportHeader: () => <div>Usage Export Header</div>,
}));
vi.mock("../../../common_components/team_multi_select", () => ({
default: () => <div>Team Multi Select</div>,
}));
// Mock useTeams hook
vi.mock("@/app/(dashboard)/hooks/useTeams", () => ({
default: vi.fn(() => ({
@@ -25,6 +25,7 @@ import {
import { ExportOutlined, LoadingOutlined } from "@ant-design/icons";
import { Alert, Button } from "antd";
import React, { useMemo, useState } from "react";
import TeamMultiSelect from "../../../common_components/team_multi_select";
import { ActivityMetrics, processActivityData } from "../../../activity_metrics";
import { UsageExportHeader } from "../../../EntityUsageExport";
import type { EntityType } from "../../../EntityUsageExport/types";
@@ -468,11 +469,20 @@ const EntityUsage: React.FC<EntityUsageProps> = ({ accessToken, entityType, enti
}
/>
)}
{entityType === "team" && (
<div className="mb-4">
<Text className="mb-2">Filter by team</Text>
<TeamMultiSelect
value={selectedTags}
onChange={setSelectedTags}
/>
</div>
)}
<UsageExportHeader
dateValue={dateValue}
entityType={entityType}
spendData={spendData}
showFilters={entityList !== null && entityList.length > 0}
showFilters={entityType !== "team" && entityList !== null && entityList.length > 0}
filterLabel={getFilterLabel(entityType)}
filterPlaceholder={getFilterPlaceholder(entityType)}
selectedFilters={selectedTags}
@@ -0,0 +1,112 @@
import React, { useMemo, useState, type UIEvent } from "react";
import { Select, Typography } from "antd";
import { LoadingOutlined } from "@ant-design/icons";
import { useDebouncedState } from "@tanstack/react-pacer/debouncer";
import { useInfiniteTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import { Team } from "../key_team_helpers/key_list";
const { Text } = Typography;
interface TeamMultiSelectProps {
value?: string[];
onChange?: (value: string[]) => void;
disabled?: boolean;
organizationId?: string | null;
pageSize?: number;
placeholder?: string;
}
const SCROLL_THRESHOLD = 0.8;
const DEBOUNCE_MS = 300;
const TeamMultiSelect: React.FC<TeamMultiSelectProps> = ({
value = [],
onChange,
disabled,
organizationId,
pageSize = 20,
placeholder = "Search teams by alias...",
}) => {
const [searchInput, setSearchInput] = useState("");
const [debouncedSearch, setDebouncedSearch] = useDebouncedState("", {
wait: DEBOUNCE_MS,
});
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
} = useInfiniteTeams(
pageSize,
debouncedSearch || undefined,
organizationId,
);
const teams = useMemo(() => {
if (!data?.pages) return [];
const seen = new Set<string>();
const result: Team[] = [];
for (const page of data.pages) {
for (const team of page.teams) {
if (seen.has(team.team_id)) continue;
seen.add(team.team_id);
result.push(team);
}
}
return result;
}, [data]);
const handlePopupScroll = (e: UIEvent<HTMLDivElement>) => {
const target = e.currentTarget;
const scrollRatio =
(target.scrollTop + target.clientHeight) / target.scrollHeight;
if (scrollRatio >= SCROLL_THRESHOLD && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
};
const handleSearch = (val: string) => {
setSearchInput(val);
setDebouncedSearch(val);
};
return (
<Select
mode="multiple"
showSearch
placeholder={placeholder}
value={value}
onChange={(val: string[]) => onChange?.(val)}
disabled={disabled}
allowClear
filterOption={false}
onSearch={handleSearch}
searchValue={searchInput}
onPopupScroll={handlePopupScroll}
loading={isLoading}
notFoundContent={isLoading ? <LoadingOutlined spin /> : "No teams found"}
style={{ width: "100%" }}
popupRender={(menu) => (
<>
{menu}
{isFetchingNextPage && (
<div style={{ textAlign: "center", padding: 8 }}>
<LoadingOutlined spin />
</div>
)}
</>
)}
>
{teams.map((team) => (
<Select.Option key={team.team_id} value={team.team_id}>
<span className="font-medium">{team.team_alias}</span>{" "}
<Text type="secondary">({team.team_id})</Text>
</Select.Option>
))}
</Select>
);
};
export default TeamMultiSelect;
@@ -15,6 +15,10 @@ export interface Team {
keys: KeyResponse[];
members_with_roles: Member[];
spend: number;
access_group_ids?: string[];
access_group_models?: string[];
access_group_mcp_server_ids?: string[];
access_group_agent_ids?: string[];
}
export interface KeyResponse {
@@ -7,6 +7,7 @@ export interface FilterOptionCustomComponentProps {
value?: string;
onChange: (value: string) => void;
placeholder?: string;
allFilters?: { [key: string]: string };
}
export interface FilterOption {
@@ -209,6 +210,7 @@ const FilterComponent: React.FC<FilterComponentProps> = ({
value={tempValues[option.name] || undefined}
onChange={(value) => handleFilterChange(option.name, value ?? "")}
placeholder={`Select ${option.label || option.name}...`}
allFilters={tempValues}
/>
);
})()
@@ -3267,6 +3267,7 @@ export const keyAliasesCall = async (
page: number = 1,
size: number = 50,
search?: string,
team_id?: string,
): Promise<PaginatedKeyAliasResponse> => {
/**
* Get key aliases from proxy with pagination and optional search
@@ -3277,6 +3278,7 @@ export const keyAliasesCall = async (
page: String(page),
size: String(size),
...(search ? { search } : {}),
...(team_id ? { team_id } : {}),
}),
);
let url = proxyBaseUrl ? `${proxyBaseUrl}/key/aliases` : `/key/aliases`;
@@ -95,6 +95,9 @@ export interface TeamData {
} | null;
created_at: string;
access_group_ids?: string[];
access_group_models?: string[];
access_group_mcp_server_ids?: string[];
access_group_agent_ids?: string[];
guardrails?: string[];
policies?: string[];
object_permission?: {
@@ -491,7 +494,9 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
...(secretManagerSettings !== undefined ? { secret_manager_settings: secretManagerSettings } : {}),
},
...(values.policies?.length > 0 ? { policies: values.policies } : {}),
organization_id: values.organization_id,
...(values.organization_id !== info.organization_id
? { organization_id: values.organization_id ?? null }
: {}),
};
updateData.max_budget = mapEmptyStringToNull(updateData.max_budget);
@@ -655,14 +660,21 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
<Card>
<Text>Models</Text>
<div className="mt-2 flex flex-wrap gap-2">
{info.models.length === 0 ? (
{info.models.length === 0 || info.models.includes("all-proxy-models") ? (
<Badge color="red">All proxy models</Badge>
) : (
info.models.map((model, index) => (
<Badge key={index} color="red">
{model}
</Badge>
))
<>
{info.models.map((model: string, index: number) => (
<Badge key={`direct-${index}`} color="blue">
{model}
</Badge>
))}
{(info.access_group_models || []).map((model: string, index: number) => (
<Badge key={`ag-${index}`} color="green" title="From access group">
{model}
</Badge>
))}
</>
)}
</div>
</Card>
@@ -1077,8 +1089,17 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
/>
</Form.Item>
<Form.Item label="Organization ID" name="organization_id">
<Input type="" disabled />
<Form.Item label="Organization" name="organization_id">
<Select
allowClear
placeholder="Select an organization"
showSearch
optionFilterProp="label"
options={userOrganizations.map((org) => ({
value: org.organization_id,
label: org.organization_alias || org.organization_id,
}))}
/>
</Form.Item>
<Form.Item label="Logging Settings" name="logging_settings">