diff --git a/docs/my-website/docs/proxy/config_settings.md b/docs/my-website/docs/proxy/config_settings.md index cc9090c2de..27d9aed52b 100644 --- a/docs/my-website/docs/proxy/config_settings.md +++ b/docs/my-website/docs/proxy/config_settings.md @@ -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 diff --git a/docs/my-website/docs/proxy/self_serve.md b/docs/my-website/docs/proxy/self_serve.md index b54344c1d0..639cd05d01 100644 --- a/docs/my-website/docs/proxy/self_serve.md +++ b/docs/my-website/docs/proxy/self_serve.md @@ -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 diff --git a/docs/my-website/docs/tutorials/msft_sso.md b/docs/my-website/docs/tutorials/msft_sso.md index 2936f27297..06cc2e2aa5 100644 --- a/docs/my-website/docs/tutorials/msft_sso.md +++ b/docs/my-website/docs/tutorials/msft_sso.md @@ -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 diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 4f3e51a2a3..0fad081770 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -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): diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index 6f9166909c..497ddcbbe8 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -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}' diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 3643373be6..e153457378 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -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, diff --git a/litellm/types/proxy/management_endpoints/team_endpoints.py b/litellm/types/proxy/management_endpoints/team_endpoints.py index 5055a65783..2455eb495d 100644 --- a/litellm/types/proxy/management_endpoints/team_endpoints.py +++ b/litellm/types/proxy/management_endpoints/team_endpoints.py @@ -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): diff --git a/poetry.lock b/poetry.lock index dff1b8781d..ca64f3101b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -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" diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py index e11dbbd891..232c698603 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py @@ -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 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeyAliases.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeyAliases.test.ts index b382b1f2ad..1e1190b12c 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeyAliases.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeyAliases.test.ts @@ -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); }); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeyAliases.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeyAliases.ts index f67b15f3a9..03e96fe73c 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeyAliases.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeyAliases.ts @@ -8,6 +8,7 @@ const infiniteKeyAliasKeys = createQueryKeys("infiniteKeyAliases"); export const useInfiniteKeyAliases = ( size: number = 50, search?: string, + team_id?: string, ) => { const { accessToken } = useAuthorized(); return useInfiniteQuery({ @@ -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, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsTable/ModelsCell.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsTable/ModelsCell.test.tsx index 747ce518cf..2b487d6532 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsTable/ModelsCell.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsTable/ModelsCell.test.tsx @@ -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(); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsTable/ModelsCell.tsx b/ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsTable/ModelsCell.tsx index 5cabe4c4a8..62a7fdb783 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsTable/ModelsCell.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/teams/components/TeamsTable/ModelsCell.tsx @@ -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(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 ( + + All Proxy Models + + ); + } + const displayName = getModelDisplayName(entry.name); + const truncated = displayName.length > 30 ? `${displayName.slice(0, 30)}...` : displayName; + return ( + + {truncated} + + ); + }; + return ( { whiteSpace: "pre-wrap", overflow: "hidden", }} - className={team.models.length > 3 ? "px-0" : ""} + className={modelEntries.length > 3 ? "px-0" : ""} >
- {Array.isArray(team.models) ? ( + {modelEntries.length === 0 ? ( + + All Proxy Models + + ) : (
- {team.models.length === 0 ? ( - - All Proxy Models - - ) : ( - <> -
- {team.models.length > 3 && ( -
- { - setExpandedAccordion((prev) => !prev); - }} - /> -
- )} -
- {team.models.slice(0, 3).map((model: string, index: number) => - model === "all-proxy-models" ? ( - - All Proxy Models - - ) : ( - - - {model.length > 30 - ? `${getModelDisplayName(model).slice(0, 30)}...` - : getModelDisplayName(model)} - - - ), - )} - {team.models.length > 3 && !expandedAccordion && ( - - - +{team.models.length - 3} {team.models.length - 3 === 1 ? "more model" : "more models"} - - - )} - {expandedAccordion && ( -
- {team.models.slice(3).map((model: string, index: number) => - model === "all-proxy-models" ? ( - - All Proxy Models - - ) : ( - - - {model.length > 30 - ? `${getModelDisplayName(model).slice(0, 30)}...` - : getModelDisplayName(model)} - - - ), - )} -
- )} -
+
+ {modelEntries.length > 3 && ( +
+ { + setExpandedAccordion((prev) => !prev); + }} + />
- - )} + )} +
+ {modelEntries.slice(0, 3).map((entry, index) => renderBadge(entry, index))} + {modelEntries.length > 3 && !expandedAccordion && ( + + + +{modelEntries.length - 3} {modelEntries.length - 3 === 1 ? "more model" : "more models"} + + + )} + {expandedAccordion && ( +
+ {modelEntries.slice(3).map((entry, index) => renderBadge(entry, index + 3))} +
+ )} +
+
- ) : null} + )}
); diff --git a/ui/litellm-dashboard/src/components/KeyAliasSelect/PaginatedKeyAliasSelect/PaginatedKeyAliasSelect.test.tsx b/ui/litellm-dashboard/src/components/KeyAliasSelect/PaginatedKeyAliasSelect/PaginatedKeyAliasSelect.test.tsx index 9a3755124b..79a002cc5a 100644 --- a/ui/litellm-dashboard/src/components/KeyAliasSelect/PaginatedKeyAliasSelect/PaginatedKeyAliasSelect.test.tsx +++ b/ui/litellm-dashboard/src/components/KeyAliasSelect/PaginatedKeyAliasSelect/PaginatedKeyAliasSelect.test.tsx @@ -112,7 +112,7 @@ describe("PaginatedKeyAliasSelect", () => { it("should pass pageSize to useInfiniteKeyAliases", () => { renderWithProviders(); - 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); }); }); diff --git a/ui/litellm-dashboard/src/components/KeyAliasSelect/PaginatedKeyAliasSelect/PaginatedKeyAliasSelect.tsx b/ui/litellm-dashboard/src/components/KeyAliasSelect/PaginatedKeyAliasSelect/PaginatedKeyAliasSelect.tsx index 0bec77ca52..940f0b7e95 100644 --- a/ui/litellm-dashboard/src/components/KeyAliasSelect/PaginatedKeyAliasSelect/PaginatedKeyAliasSelect.tsx +++ b/ui/litellm-dashboard/src/components/KeyAliasSelect/PaginatedKeyAliasSelect/PaginatedKeyAliasSelect.tsx @@ -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 []; diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.test.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.test.tsx index 5c23cf71ab..dc201cccfe 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.test.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.test.tsx @@ -45,6 +45,10 @@ vi.mock("../../../EntityUsageExport", () => ({ UsageExportHeader: () =>
Usage Export Header
, })); +vi.mock("../../../common_components/team_multi_select", () => ({ + default: () =>
Team Multi Select
, +})); + // Mock useTeams hook vi.mock("@/app/(dashboard)/hooks/useTeams", () => ({ default: vi.fn(() => ({ diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx index aaeb8ebb4b..9fa73dd172 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx @@ -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 = ({ accessToken, entityType, enti } /> )} + {entityType === "team" && ( +
+ Filter by team + +
+ )} 0} + showFilters={entityType !== "team" && entityList !== null && entityList.length > 0} filterLabel={getFilterLabel(entityType)} filterPlaceholder={getFilterPlaceholder(entityType)} selectedFilters={selectedTags} diff --git a/ui/litellm-dashboard/src/components/common_components/team_multi_select.tsx b/ui/litellm-dashboard/src/components/common_components/team_multi_select.tsx new file mode 100644 index 0000000000..6f00a4d1f7 --- /dev/null +++ b/ui/litellm-dashboard/src/components/common_components/team_multi_select.tsx @@ -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 = ({ + 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(); + 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) => { + 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 ( + + ); +}; + +export default TeamMultiSelect; diff --git a/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx b/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx index a681e438cd..04b9a5c996 100644 --- a/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx +++ b/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx @@ -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 { diff --git a/ui/litellm-dashboard/src/components/molecules/filter.tsx b/ui/litellm-dashboard/src/components/molecules/filter.tsx index dcf22293f8..34ff1983f3 100644 --- a/ui/litellm-dashboard/src/components/molecules/filter.tsx +++ b/ui/litellm-dashboard/src/components/molecules/filter.tsx @@ -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 = ({ value={tempValues[option.name] || undefined} onChange={(value) => handleFilterChange(option.name, value ?? "")} placeholder={`Select ${option.label || option.name}...`} + allFilters={tempValues} /> ); })() diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index a00441e703..7f8dea4cbf 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -3267,6 +3267,7 @@ export const keyAliasesCall = async ( page: number = 1, size: number = 50, search?: string, + team_id?: string, ): Promise => { /** * 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`; diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx index a4c7ae2bbb..abc967bb98 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx @@ -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 = ({ ...(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 = ({ Models
- {info.models.length === 0 ? ( + {info.models.length === 0 || info.models.includes("all-proxy-models") ? ( All proxy models ) : ( - info.models.map((model, index) => ( - - {model} - - )) + <> + {info.models.map((model: string, index: number) => ( + + {model} + + ))} + {(info.access_group_models || []).map((model: string, index: number) => ( + + {model} + + ))} + )}
@@ -1077,8 +1089,17 @@ const TeamInfoView: React.FC = ({ /> - - + +