Skip to content

[Feature] /v2/team/list: Add org admin access control, members_count, and indexes#23938

Merged
yuneng-jiang merged 5 commits intolitellm_yj_march_17_2026from
litellm_/sweet-austin
Mar 18, 2026
Merged

[Feature] /v2/team/list: Add org admin access control, members_count, and indexes#23938
yuneng-jiang merged 5 commits intolitellm_yj_march_17_2026from
litellm_/sweet-austin

Conversation

@yuneng-jiang
Copy link
Copy Markdown
Contributor

Relevant issues

N/A - internal improvement to support UI migration to /v2/team/list

Summary

Problem

The existing /v2/team/list endpoint is missing org admin access control. Org admins get a 401 when trying to list teams, even though the legacy /team/list endpoint supports them. The response also lacks members_count, forcing the UI to compute it client-side or fetch full membership data.

Fix

  • Ported org admin access control logic from _authorize_and_filter_teams (legacy endpoint) into list_team_v2. Org admins can now list teams scoped to their organizations.
  • Added TeamListItem response model with a members_count field computed from members_with_roles.
  • Added missing @@index directives on LiteLLM_TeamTable for organization_id, team_alias, and created_at (the deleted team table already had these).

Changes

Access control flow in list_team_v2 now: proxy admin → standard route check → org admin check → reject. The org admin check uses get_user_object to look up organization memberships and auto-injects an organization_id IN [...] filter into the Prisma query. _build_team_list_where_conditions was extended with an org_admin_org_ids parameter. Schema indexes were added to all 3 copies of schema.prisma.

Testing

  • All 103 existing unit tests in test_team_endpoints.py pass
  • Manually verified via unit tests: org admin sees org-scoped teams (200), org admin cannot view other orgs (403), regular user auto-inject still works, admin sees all teams, members_count computed correctly, members_with_roles=None handled safely, pagination metadata correct

Type

🆕 New Feature

… and indexes

Add org admin support to /v2/team/list so org admins can list teams
within their organizations instead of getting 401. Also enrich the
response with members_count and add missing indexes.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
@vercel
Copy link
Copy Markdown

vercel bot commented Mar 18, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
litellm Ready Ready Preview, Comment Mar 18, 2026 4:58am

Request Review

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 18, 2026

Greptile Summary

This PR ports org admin access control from the legacy /team/list endpoint into /v2/team/list, adds a members_count field to the team list response, and adds three missing @@index directives on LiteLLM_TeamTable across all three Prisma schema files. The access control refactor introduces a new _get_org_admin_org_ids helper that is called on every non-proxy-admin request, and the existing _build_team_list_where_conditions helper is updated to use get_user_object instead of a raw Prisma call.

Key points from this review:

  • Exception scope narrowed in _build_team_list_where_conditions: except Exceptionexcept ValueError changes the observable error for non-ValueError failures (DB timeouts, etc.) from 404 to an unhandled 500.
  • Fragile call-order mock in test_list_team_v2_org_admin_with_user_id_returns_user_teams: The call_count counter couples test correctness to invocation order; keying on user_id kwarg is safer.
  • Several open concerns from prior threads remain: extra DB/cache lookup on every non-admin request, org scope not enforced when user_id filter is provided, silent discard of user_id filter for org admins, and members_with_roles fully loaded just to compute len().
  • The schema index additions and TeamListItem model are clean and straightforward.

Confidence Score: 3/5

  • The core access control logic works for the main happy paths, but multiple open concerns from prior threads (org scope bypass with user_id filter, extra DB lookup on every non-admin request, silent user_id discard for org admins) remain unresolved and the exception-scope narrowing introduces a subtle behavior change.
  • New tests cover the primary org admin scenarios and the schema/type changes are safe. However, the combination of unresolved prior-thread issues around access control completeness and the behavior change from narrowing the exception catch in _build_team_list_where_conditions warrants extra scrutiny before merge.
  • litellm/proxy/management_endpoints/team_endpoints.py — specifically the _get_org_admin_org_ids call path, the user_id + org_admin_org_ids interaction in _build_team_list_where_conditions, and the narrowed exception handling at line 3292.

Important Files Changed

Filename Overview
litellm/proxy/management_endpoints/team_endpoints.py Added org admin access control to list_team_v2, including _get_org_admin_org_ids helper and updated _build_team_list_where_conditions. Several existing threads flag open issues: exception swallowing, extra DB lookup per non-admin request, org scope gaps when user_id is combined with org admin path, and behavioral divergence from legacy endpoint.
litellm/types/proxy/management_endpoints/team_endpoints.py Added TeamListItem extending LiteLLM_TeamTable with a members_count field. Simple, clean model addition.
tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py Updated existing tests to mock the new get_user_object dependency, and added three new org admin tests. The call_count-based mock dispatch in test_list_team_v2_org_admin_with_user_id_returns_user_teams is fragile; keying on user_id kwarg is more robust.
litellm/proxy/schema.prisma Added @@index directives on organization_id, team_alias, and created_at to LiteLLM_TeamTable. Additive, backward-compatible schema change.
schema.prisma Same index additions as litellm/proxy/schema.prisma, applied to the root schema copy. Consistent across all three schema locations.
litellm-proxy-extras/litellm_proxy_extras/schema.prisma Same @@index additions applied to the third schema copy. Consistent change across all three Prisma schema files.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[list_team_v2 called] --> B{is_proxy_admin?}
    B -- Yes --> F[Build where conditions\nno user_id injected]
    B -- No --> C[_get_org_admin_org_ids\ncalls get_user_object for caller]
    C --> D{org_admin_org_ids\nnot None?}
    D -- Yes --> E{organization_id\nfilter provided?}
    E -- Yes, not in org_ids --> ERR403[Raise 403]
    E -- No / in org_ids --> F
    D -- No --> G{allowed_route_check\npasses?}
    G -- No --> ERR401[Raise 401]
    G -- Yes --> H[Auto-inject caller user_id\nif not provided]
    H --> F
    F --> I[_build_team_list_where_conditions]
    I --> J{user_id provided?}
    J -- Yes --> K[get_user_object for user_id]
    K --> L{user has teams?}
    L -- No --> M[Return None\nearly exit → empty result]
    L -- Yes --> N{org_admin_org_ids AND\nno organization_id?}
    J -- No --> N
    N -- Yes --> O[Inject org_id IN org_admin_org_ids]
    N -- No --> P[Keep existing filters]
    O --> Q[Query DB + build TeamListItem\nwith members_count]
    P --> Q
    M --> R[Return empty paginated response]
    Q --> S[Return paginated response]
Loading

Last reviewed commit: "fix: document org sc..."

Comment on lines +3264 to +3267
if organization_id:
where_conditions["organization_id"] = organization_id
# If a list is passed (org admin viewing multiple orgs), use IN
if isinstance(organization_id, list):
where_conditions["organization_id"] = {"in": organization_id}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Dead code — organization_id can never be a list

organization_id is declared as Optional[str] (a FastAPI Query parameter), so the isinstance(organization_id, list) branch is unreachable. The comment above it ("If a list is passed …") suggests this was written for a future or hypothetical calling convention that does not exist. This dead branch adds maintenance confusion and should be removed.

Suggested change
if organization_id:
where_conditions["organization_id"] = organization_id
# If a list is passed (org admin viewing multiple orgs), use IN
if isinstance(organization_id, list):
where_conditions["organization_id"] = {"in": organization_id}
where_conditions["organization_id"] = organization_id

@@ -1,3 +1,4 @@
from datetime import datetime
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Unused datetime import

datetime is imported but not referenced anywhere in this file. TeamListItem does not declare any datetime fields, and the parent class LiteLLM_TeamTable handles its own datetime imports internally.

Suggested change
from datetime import datetime
from typing import Any, Dict, List, Optional, Union

Comment on lines +3411 to +3455
if not is_proxy_admin:
# First check if the standard route check passes (user querying own data)
passes_route_check = allowed_route_check_inside_route(
user_api_key_dict=user_api_key_dict, requested_user_id=user_id
)

if user_id is None and user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN:
user_id = user_api_key_dict.user_id
if not passes_route_check:
# Standard check failed — see if the caller is an org admin
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 None:
# Not an org admin either — reject
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
)
},
)

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_api_key_dict.user_id,
org_admin_org_ids,
)
elif not is_proxy_admin:
# Regular user — auto-inject caller's user_id
if user_id is None:
user_id = user_api_key_dict.user_id
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Org admin querying own user_id silently downgrades to regular-user scope

When an org admin calls the endpoint with user_id=<their own ID>, allowed_route_check_inside_route returns True (because user_id == user_api_key_dict.user_id). This short-circuits before the org admin lookup runs, so org_admin_org_ids stays None. The code then falls into the elif not is_proxy_admin: branch and injects the caller's user_id, giving the org admin only their direct team memberships — not all teams in their organisation.

Compare this with the legacy _authorize_and_filter_teams (line 3568) which explicitly comments:

# Check if user is an org admin (even for own queries, so they see org teams)

and always fetches allowed_org_ids regardless of is_own_query.

The fix is to move the org admin DB lookup before the passes_route_check short-circuit, then use org_admin_org_ids is None and not passes_route_check as the rejection condition. This mirrors the legacy endpoint's intent and ensures org admins always see org-scoped teams even when querying with their own user_id.

- Fix org admin own-query regression: always check org admin status
  before the standard route check so own-queries see all org teams
- Clear user_id when org admin is detected so org scope replaces
  user-membership scope
- Remove dead isinstance(organization_id, list) branch
- Remove unused datetime import
- Remove orphaned _convert_teams_to_response helper

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Comment on lines +3397 to +3409
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."
},
)
# Org admin scope replaces user_id scope — the org filter
# already returns all teams in their orgs, so we don't also
# restrict by the user's direct team memberships.
user_id = None
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Behavioral divergence from legacy endpoint: org admin direct memberships are dropped

The legacy _authorize_and_filter_teams explicitly returns org teams + any teams the org admin is a direct member of (even teams whose organization_id is not one of theirs), as documented in its docstring:

Org admins: teams from their orgs + teams they are direct members of.

The new code unconditionally sets user_id = None and scopes results purely to org_admin_org_ids. This means if an org admin is a direct member of a team that belongs to a different org (or no org), that team will be visible on /team/list but missing from /v2/team/list.

Because this PR is framed as a UI migration from the legacy endpoint, this silent drop in scope could cause org admins to see fewer teams in the new UI without any explanation. If the narrower scope is intentional, it should be documented in the PR description and in the endpoint docstring.

Comment on lines +3406 to +3414
# Org admin scope replaces user_id scope — the org filter
# already returns all teams in their orgs, so we don't also
# restrict by the user's direct team memberships.
user_id = None
verbose_proxy_logger.debug(
"list_team_v2: org admin access for user=%s, org_ids=%s",
user_api_key_dict.user_id,
org_admin_org_ids,
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Caller-supplied user_id query param is silently discarded for org admins

When an org admin passes ?user_id=X to filter teams by a specific user, the code unconditionally sets user_id = None and returns all org-scoped teams instead. There is no error, no warning log, and no documentation in the endpoint's docstring explaining that user_id is ignored for org admins. A caller who legitimately wants to filter teams by user within their org will receive unexpected, unfiltered results.

At minimum, add a note to the docstring, or consider logging a debug message when a non-None user_id is discarded:

            if user_id is not None:
                verbose_proxy_logger.debug(
                    "list_team_v2: org admin user_id filter (%s) ignored; using org scope instead",
                    user_id,
                )
            user_id = None

Comment on lines +3221 to +3230
try:
caller_user = await get_user_object(
user_id=user_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
user_id_upsert=False,
proxy_logging_obj=proxy_logging_obj,
)
except Exception:
return None
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Silent exception swallowing demotes org admins without any trace

The bare except Exception: return None silently discards any error from get_user_object (DB timeout, connection error, unexpected schema, etc.). When that happens the function returns None, org_admin_org_ids stays None, and the org admin falls through to the allowed_route_check_inside_route branch — producing a 401 or narrowed scope with no log entry to diagnose why.

Note that the legacy _authorize_and_filter_teams (line 3544) does not wrap get_user_object in a try/except — it lets the error propagate, which is the safer behaviour.

At minimum, log the caught exception before returning None so the problem is surfaced in the proxy logs:

    except Exception as e:
        verbose_proxy_logger.warning(
            "_get_org_admin_org_ids: failed to fetch user=%s: %s",
            user_id,
            str(e),
        )
        return None

- _get_org_admin_org_ids: catch only ValueError (user not found) instead
  of bare Exception — DB errors now propagate as 500s instead of silently
  demoting org admins to regular users
- _build_team_list_where_conditions: return None (not a sentinel string)
  when user has no team memberships; list_team_v2 short-circuits to empty
  response without hitting the DB
- Org admin + team_id + user_id: use exact team_id match with org scope
  instead of OR expansion that effectively ignored the team_id filter
- Org admin + user_id (no team_id): OR(org teams, direct memberships)
  now matches legacy _authorize_and_filter_teams behaviour

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Comment on lines +3526 to +3531
members_with_roles = team_dict.get("members_with_roles")
if not isinstance(members_with_roles, list):
members_with_roles = []
team_dict["members_with_roles"] = members_with_roles
members_count = len(members_with_roles)
team_list.append(TeamListItem(**team_dict, members_count=members_count))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 members_with_roles fully loaded just to compute a count

Every team's members_with_roles JSON blob is fetched from the database and deserialized into Python memory purely to call len() on it. For large deployments where teams have hundreds or thousands of members this needlessly inflates the query payload.

If a lightweight count is needed, consider storing members_count as a denormalized integer column on LiteLLM_TeamTable that is incremented/decremented on add/remove operations, or use a Prisma _count aggregate in the select clause, so the wire payload stays small regardless of team size.

Comment on lines +3403 to +3412
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,
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Extra DB/cache lookup added to every non-admin request

_get_org_admin_org_ids (which calls get_user_object) is now invoked unconditionally for every non-proxy-admin call to /v2/team/list, even for plain regular users who will never be org admins. While get_user_object has a cache layer, a cache miss still issues a DB query and the added cache-hit overhead is on every request.

Consider short-circuiting the check using a cheaper signal first — e.g. checking user_api_key_dict.user_role against LitellmUserRoles.ORG_ADMIN before calling into _get_org_admin_org_ids. If the JWT/key auth already has the role set, no extra DB call is needed.

Comment on lines +3290 to +3303
elif org_admin_org_ids is not None:
# Org admin with user_id filter: show teams in their orgs
# OR teams the user is a direct member of (matches legacy
# _authorize_and_filter_teams behaviour).
# When team_id is also provided, the exact match is already in
# where_conditions and the org scope just needs to be added —
# no OR expansion needed.
if team_id is not None:
where_conditions["organization_id"] = {"in": org_admin_org_ids}
elif user_team_ids:
org_condition: Dict[str, Any] = {"organization_id": {"in": org_admin_org_ids}}
where_conditions["OR"] = [org_condition, {"team_id": {"in": user_team_ids}}]
else:
where_conditions["organization_id"] = {"in": org_admin_org_ids}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Explicit organization_id filter silently widened when team_id + user_id are also set

When an org admin calls the endpoint with ?organization_id=org1&user_id=user1&team_id=team1, the following happens:

  1. Line 3271 sets where_conditions["organization_id"] = "org1" (the caller's explicit filter).
  2. Line 3297–3298 then overwrites it with {"in": org_admin_org_ids}, which may include org1, org2, org3, etc.

The result is that the query searches all of the admin's organizations instead of just org1, directly contradicting the caller's filter. The access-control check (line 3416) guarantees org1 is valid, but it does not prevent the scope from being silently expanded.

The fix is to honour the already-set organization_id value when team_id is also present:

            if team_id is not None:
                # team_id is already in where_conditions; keep the
                # user-supplied organization_id if present, otherwise
                # scope to all admin orgs.
                if "organization_id" not in where_conditions:
                    where_conditions["organization_id"] = {"in": org_admin_org_ids}

Comment on lines +3277 to +3279
user_object = await prisma_client.db.litellm_usertable.find_unique(
where={"user_id": user_id}
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Direct DB query violates helper-function rule

prisma_client.db.litellm_usertable.find_unique is a raw Prisma call in the request path. The repository policy requires all DB access to go through the canonical helper functions (get_user_object, get_team_object, etc.) so that caching, metrics, and circuit-breaking are applied uniformly.

The get_user_object helper already exists, supports caching via user_api_key_cache, and is used correctly by _get_org_admin_org_ids just above. The same helper should be used here instead of calling Prisma directly, which would require threading user_api_key_cache and proxy_logging_obj into _build_team_list_where_conditions — both are already available in list_team_v2.

Rule Used: What: In critical path of request, there should be... (source)

- Replace raw find_unique with get_user_object in
  _build_team_list_where_conditions for cache/metrics consistency
- Remove over-complex OR clause for org admin + user_id: when user_id
  is provided, filter by that user's direct team memberships (same as
  regular users) since the access control gate already verified the
  org admin's authority
- Preserve caller-supplied organization_id instead of overwriting with
  org_admin_org_ids
- Update test mock to match get_user_object call path

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Comment on lines +3415 to +3445
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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 No unit tests cover the new org-admin access control paths

This PR introduces two new code paths that are exercised only through the org-admin branch:

  • org admin with a valid organization_id filter → 200
  • org admin with an invalid organization_id filter → 403
  • org admin without any filter → org-scoped teams

The test diff shows only updates to test_list_team_v2_security_check_non_admin_user_own_teams to accommodate the new get_user_object mocking; no new test functions covering these paths appear in the diff. The PR description says these scenarios were "manually verified via unit tests," but since these tests aren't in the repo they won't protect against regressions.

At minimum, the following cases should have dedicated tests:

  1. An org admin whose organization_id filter is within their orgs gets 200 with org-scoped results.
  2. An org admin whose organization_id filter is from a different org gets 403.
  3. A user with no org memberships is rejected with 401 (verifying the org-admin path doesn't accidentally grant access).

Comment on lines 3272 to +3276
if organization_id:
where_conditions["organization_id"] = organization_id
elif org_admin_org_ids is not None and not user_id:
# Org admin without explicit org or user filter: scope to their orgs
where_conditions["organization_id"] = {"in": org_admin_org_ids}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Org scope is silently dropped when org admin provides user_id + no organization_id

When an org admin calls the endpoint with ?user_id=<anyone> but no organization_id, the condition:

elif org_admin_org_ids is not None and not user_id:
    where_conditions["organization_id"] = {"in": org_admin_org_ids}

evaluates to False (because user_id is truthy), so no organisation filter is injected. The resulting query then returns all teams of the specified user — from any organisation on the platform — without validating that those teams belong to the org admin's organisations.

The legacy _authorize_and_filter_teams is also permissive here (it returns org-teams ∪ user's-member-teams), so this isn't a regression relative to the legacy endpoint. However, the endpoint docstring and the PR's stated invariant ("Org admins can query teams within their organizations") both imply a narrower scope than what is actually enforced.

If the intention is to allow an org admin to look up teams for an arbitrary user, this should be explicitly documented in the docstring. If the intention is strict org-scoping, consider applying both filters simultaneously:

elif org_admin_org_ids is not None:
    conditions = {"organization_id": {"in": org_admin_org_ids}}
    if not user_id:
        where_conditions.update(conditions)
    # user_id path will further intersect via team_id IN user_team_ids

- Document intentional legacy-matching behavior: when user_id is
  provided to an org admin, no org filter is applied (returns all of
  that user's teams across all orgs, same as legacy endpoint)
- Fix two existing security tests to properly patch user_api_key_cache,
  proxy_logging_obj, and get_user_object instead of relying on
  incidental error handling
- Add three new org admin test cases:
  - Org admin sees org-scoped teams (200 with correct where clause)
  - Org admin rejected when filtering by other org (403)
  - Org admin with user_id filter returns target user's teams

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Comment on lines +2518 to +2527
call_count = 0

async def mock_get_user_object(**kwargs):
nonlocal call_count
call_count += 1
# First call: org admin lookup in list_team_v2
# Second call: target user lookup in _build_team_list_where_conditions
if call_count == 1:
return mock_org_admin
return mock_target_user
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Fragile call-order dependency in mock

The call_count counter ties the test's correctness to the exact invocation order of get_user_object across two separate internal functions (_get_org_admin_org_ids and _build_team_list_where_conditions). If either function is refactored to reorder, add, or cache the call, the mock silently returns the wrong user without any assertion failure, making bugs very hard to diagnose.

A more robust pattern is to use side_effect keyed on the user_id kwarg so the mock is order-independent:

async def mock_get_user_object(**kwargs):
    uid = kwargs.get("user_id")
    if uid == "org_admin_user":
        return mock_org_admin
    if uid == "target_user":
        return mock_target_user
    return None

Comment on lines +3292 to 3296
except ValueError:
raise HTTPException(
status_code=404,
detail={"error": f"User not found, passed user_id={user_id}"},
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Narrowed exception scope changes observable error behavior

The old code caught except Exception and converted all get_user_object failures into a 404 User not found. The new code only catches ValueError. This means any non-ValueError exception from get_user_object (DB connection timeout, prisma_client being None inside the helper, unexpected schema error, etc.) will now propagate up as an unhandled 500 instead of a 404.

While semantically more correct (a DB timeout is not a "user not found"), this is a observable behavior change for API callers who previously received a 404 and may now receive a 500. If the only exception get_user_object ever raises for a missing user is indeed ValueError, then a comment to that effect would clarify the intent. If other exceptions are possible for the "user does not exist" case, they should also be caught:

        except (ValueError, SomeOtherExpectedError):
            raise HTTPException(
                status_code=404,
                detail={"error": f"User not found, passed user_id={user_id}"},
            )

@yuneng-jiang yuneng-jiang merged commit d2e77e7 into litellm_yj_march_17_2026 Mar 18, 2026
60 of 66 checks passed
@ishaan-berri ishaan-berri deleted the litellm_/sweet-austin branch March 26, 2026 22:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant