litellm/tests/code_coverage_tests/router_surface_check.py
narmstrong-berri a5533cbcfd
Some checks are pending
LiteLLM Rust DB / db-queries (push) Waiting to run
LiteLLM Rust / rust-lint (push) Waiting to run
LiteLLM Rust / rust-test (push) Waiting to run
LiteLLM Rust / rust-wheel (push) Waiting to run
feat(router): add the Rust router core and serve routing through it behind LITELLM_RUST
litellm-router-core implements the router core in Rust: a versioned registry with Python's
deployment ids, selection with native simple-shuffle, the retry, fallback and cooldown loop with
Python's cache keys, and per-attempt executors with a native seam for Messages to Anthropic. The
python bridge exposes it as RouterCoreHandle, whose call loop is step driven so Rust never awaits
Python. RustCore drives that loop for completions, messages, embeddings and responses and serves
everything else from a PythonCore built from the same config. A new router rule in the route
catalog opts in with LITELLM_RUST, and any config the Rust core cannot serve gets PythonCore at
construction

The call loop follows Python's fallback walk (per-target depth, a level raising its own error,
lookup by the current and requested group, "*" after specific chains), stamps retries the way an
exhausted retry loop does, and leaves health writes to the Python attempt, which records them.
RustCore builds its handle from the constructor arguments and the applied changesets and rebuilds
it when either version moves. The core unit suite runs on RustCore too
2026-10-31 12:54:27 -07:00

243 lines
9.2 KiB
Python

"""
Fence for the Router's public surface.
Code outside the router may use only the Router members listed in
router_surface_allowlist.json, and may import only the litellm.router_utils
modules listed there. The lists hold what is used today and may only shrink:
- a member or module used outside the router that is not listed fails the
check (use an existing entry point instead, or discuss widening the surface);
- a listed member or module that nothing outside the router uses any more
also fails, so the entry is removed in the same change.
"Outside" means every non-test file under litellm/ and enterprise/ except
litellm/router.py and the router's own modules. Uses from the router family
(litellm/router_utils, litellm/router_strategy) are listed separately, because
those modules become private parts of the router core in a later step.
Member uses are found the same way the boundary survey found them: attribute
access on a receiver whose name looks like a router (llm_router,
litellm_router_instance, router, ...) or on the Router class, plus
getattr/setattr/hasattr with a constant name on such a receiver. Members
include the container file endpoints Router.__init__ registers by name.
Run: python tests/code_coverage_tests/router_surface_check.py [--write]
--write regenerates the allowlist from the current tree (for shrinking it).
"""
import ast
import json
import os
import re
import sys
from typing import Dict, List, Set
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
ALLOWLIST = os.path.join(os.path.dirname(__file__), "router_surface_allowlist.json")
ROUTER_FILES = {
"litellm/router.py",
"litellm/router_api.py",
"litellm/router_config.py",
"litellm/router_core.py",
"litellm/router_python_core.py",
"litellm/router_python_host.py",
"litellm/router_rust_core.py",
"litellm/router_deprecated.py",
"litellm/router_host.py",
}
ROUTER_CLASS_FILES = ["litellm/router.py", "litellm/router_python_core.py", "litellm/router_deprecated.py"]
ROUTER_CLASS_NAMES = {"Router", "PythonCore", "DeprecatedRouterMembers"}
SCAN_ROOTS = ["litellm", "enterprise"]
SKIP_DIRS = {
".git",
"node_modules",
".venv",
"__pycache__",
"litellm-rust",
"ui",
"docs",
"cookbook",
"tests",
"test",
}
RECEIVER = re.compile(
r"(^|[._])(llm_router|router|litellm_router|litellm_router_instance|router_instance"
r"|_router|proxy_router|llm_router_instance|router_obj|mock_router|Router)$"
)
# Router.__init__ registers these with setattr(self, name, ...), so they never appear as self.<name>.
CONTAINER_FILE_ENDPOINTS = "litellm/containers/endpoints.json"
DYNAMIC_FUNCS = {"getattr", "setattr", "hasattr"}
def _area(rel: str) -> str:
if rel.startswith(("litellm/router_utils/", "litellm/router_strategy/")):
return "router_family"
return "outside"
def _is_test(rel: str) -> bool:
name = os.path.basename(rel)
return "/tests/" in rel or name.startswith("test_") or name.endswith("_test.py")
def router_members() -> Set[str]:
members: Set[str] = set()
for rel in ROUTER_CLASS_FILES:
with open(os.path.join(ROOT, rel), encoding="utf-8") as f:
tree = ast.parse(f.read())
for cls in tree.body:
if not isinstance(cls, ast.ClassDef) or cls.name not in ROUTER_CLASS_NAMES:
continue
for node in cls.body:
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
members.add(node.name)
elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
members.add(node.target.id)
elif isinstance(node, ast.Assign):
members.update(t.id for t in node.targets if isinstance(t, ast.Name))
for node in ast.walk(cls):
if (
isinstance(node, ast.Attribute)
and isinstance(node.value, ast.Name)
and node.value.id == "self"
and isinstance(node.ctx, ast.Store)
):
members.add(node.attr)
with open(os.path.join(ROOT, CONTAINER_FILE_ENDPOINTS), encoding="utf-8") as f:
for endpoint in json.load(f)["endpoints"]:
members.update((endpoint["name"], endpoint["async_name"]))
return {m for m in members if not (m.startswith("__") and m.endswith("__"))}
def _python_files():
for top in SCAN_ROOTS:
base = os.path.join(ROOT, top)
for dirpath, dirnames, filenames in os.walk(base):
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
for fn in filenames:
if fn.endswith(".py"):
path = os.path.join(dirpath, fn)
yield os.path.relpath(path, ROOT).replace(os.sep, "/"), path
def scan() -> Dict[str, Dict[str, Set[str]]]:
"""Return {"members": {area: {member: files}}, "router_utils_imports": {module: files}}."""
members = router_members()
uses: Dict[str, Dict[str, Set[str]]] = {"outside": {}, "router_family": {}}
imports: Dict[str, Set[str]] = {}
for rel, path in _python_files():
if rel in ROUTER_FILES or _is_test(rel):
continue
with open(path, encoding="utf-8") as f:
src = f.read()
has_router = "router" in src.lower()
if not has_router:
continue
try:
tree = ast.parse(src)
except SyntaxError:
continue
area = _area(rel)
for name in member_uses(tree, members):
uses[area].setdefault(name, set()).add(rel)
if area == "outside":
for node in ast.walk(tree):
for mod in _router_utils_modules(node):
imports.setdefault(mod, set()).add(rel)
return {"members": uses, "router_utils_imports": imports}
def member_uses(tree: ast.AST, members: Set[str]) -> Set[str]:
"""The Router members a module uses, on an instance or on the Router class itself."""
used: Set[str] = set()
for node in ast.walk(tree):
if isinstance(node, ast.Attribute) and node.attr in members:
if RECEIVER.search(ast.unparse(node.value)):
used.add(node.attr)
elif (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Name)
and node.func.id in DYNAMIC_FUNCS
and len(node.args) >= 2
and isinstance(node.args[1], ast.Constant)
and node.args[1].value in members
and RECEIVER.search(ast.unparse(node.args[0]))
):
used.add(node.args[1].value)
return used
def _router_utils_modules(node: ast.AST) -> List[str]:
if isinstance(node, ast.ImportFrom) and node.module:
if node.module == "litellm.router_utils":
return ["litellm.router_utils." + a.name for a in node.names]
if node.module.startswith("litellm.router_utils."):
return [node.module]
if isinstance(node, ast.Import):
return [a.name for a in node.names if a.name.startswith("litellm.router_utils.")]
return []
def build_allowlist(found) -> dict:
return {
"_doc": (
"Router members and litellm.router_utils modules used outside the router. "
"Shrink-only: see tests/code_coverage_tests/router_surface_check.py."
),
"members_outside": sorted(found["members"]["outside"]),
"members_router_family": sorted(found["members"]["router_family"]),
"router_utils_imports_outside": sorted(found["router_utils_imports"]),
}
def compare(allow: dict, found) -> List[str]:
errors: List[str] = []
sections = [
("members_outside", found["members"]["outside"], "Router member"),
("members_router_family", found["members"]["router_family"], "Router member (router family)"),
("router_utils_imports_outside", found["router_utils_imports"], "litellm.router_utils import"),
]
for key, used, what in sections:
listed = set(allow.get(key, []))
for name in sorted(set(used) - listed):
files = ", ".join(sorted(used[name]))
errors.append(
f"new {what} outside the router: {name!r} in {files}. The router surface may only "
f"shrink: use an entry point instead."
)
for name in sorted(listed - set(used)):
errors.append(
f"{what} {name!r} is listed in {os.path.basename(ALLOWLIST)} [{key}] but nothing uses it "
f"any more: remove it from the list."
)
return errors
def main(argv: List[str]) -> int:
found = scan()
if "--write" in argv:
with open(ALLOWLIST, "w", encoding="utf-8") as f:
json.dump(build_allowlist(found), f, indent=2)
f.write("\n")
print(f"wrote {ALLOWLIST}")
return 0
with open(ALLOWLIST, encoding="utf-8") as f:
allow = json.load(f)
errors = compare(allow, found)
for e in errors:
print(e)
if errors:
return 1
print(
f"router surface ok: {len(allow['members_outside'])} members outside, "
f"{len(allow['members_router_family'])} from the router family, "
f"{len(allow['router_utils_imports_outside'])} router_utils modules imported outside"
)
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))