refactor(proxy): convert resolve_nested_groups to iterative DFS

CI's recursive_detector forbids new recursive functions (it has caused
CPU spikes in the past). Switching to an explicit stack with ENTER/EXIT
frame markers preserves on-path cycle tracking: visited grows on ENTER,
shrinks on EXIT. Real cycles still log+skip; DAG-shared subtrees
re-traverse silently as before.

Same algorithm, same O(V + E) complexity, no Python recursion overhead.
All 35 tests pass unchanged.

Refs #28032
This commit is contained in:
Ashwin Upadhyay 2026-05-17 00:32:34 +05:30
parent 2dfe4cca87
commit 86f5c354d0

View file

@ -1,6 +1,6 @@
# What is this?
## Common checks for /v1/models and `/model/info`
from typing import Dict, List, Optional, Set
from typing import Dict, List, Optional, Set, Tuple
import litellm
from litellm._logging import verbose_proxy_logger
@ -52,36 +52,40 @@ def resolve_nested_groups(
Expand a group name to the full list of model names it transitively includes,
following parent -> child edges in `group_memberships`.
Cycle handling: `visited` tracks the *current recursion path* (on-path),
not every node ever seen. A revisit while still on the path is a real
cycle - we log a warning and skip the cyclic edge. DAG-shared subtrees
(e.g. A -> [B, C], B -> [D], C -> [D]) re-traverse D, and the caller
deduplicates the final list.
Iterative DFS with an explicit stack (no Python recursion - the proxy's
recursive_detector forbids new recursive functions due to past CPU spikes).
Frames are tagged ENTER/EXIT so on-path tracking still works: we add to
`visited` on ENTER, discard on EXIT. A revisit while still on the path is
a real cycle - we log and skip. DAG-shared subtrees (e.g. A -> [B, C],
B -> [D], C -> [D]) re-traverse D, and the caller deduplicates.
Cyclic edges are logged and skipped rather than raised: this runs on the
auth path and a malformed row must not 500 the proxy.
"""
if group_name in visited:
verbose_proxy_logger.warning(
"access group cycle detected at '%s' - skipping cyclic edge",
group_name,
)
return []
visited.add(group_name)
try:
resolved: List[str] = list(model_access_groups.get(group_name, []))
for child in group_memberships.get(group_name, []):
resolved.extend(
resolve_nested_groups(
group_name=child,
model_access_groups=model_access_groups,
group_memberships=group_memberships,
visited=visited,
)
ENTER, EXIT = 0, 1
resolved: List[str] = []
stack: List[Tuple[int, str]] = [(ENTER, group_name)]
while stack:
action, node = stack.pop()
if action == EXIT:
visited.discard(node)
continue
if node in visited:
verbose_proxy_logger.warning(
"access group cycle detected at '%s' - skipping cyclic edge",
node,
)
return resolved
finally:
visited.discard(group_name)
continue
visited.add(node)
# Schedule EXIT now so visited is cleaned up after this subtree finishes
stack.append((EXIT, node))
resolved.extend(model_access_groups.get(node, []))
# Reverse so siblings are visited in original (left-to-right) order
for child in reversed(group_memberships.get(node, [])):
stack.append((ENTER, child))
return resolved
def _get_models_from_access_groups(