mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-27 01:22:18 +00:00
* ci: run the unit_selection.sh shard files on every event instead of only fork pull requests Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * ci: rename fork-flag to unit-flag now that it applies on every event * test: move tests/test_litellm root and small trees into tests/unit Pure renames, no content changes. Follow-up commits in this PR fix references, merge the three files that already existed in tests/unit, keep live-provider tests in tests/test_litellm and wire CI. * test: carry tests/test_litellm conftest isolation into tests/unit Callback lists, routing fallbacks, cached HTTP clients, logger state, AWS, proxy-URL and keychain env, and session-end client cleanup now reset for unit tests too. The environment isolation owns its MonkeyPatch so a test's own monkeypatch is undone before the model-cost teardown runs. * test: merge, split and prune the moved root and small-tree tests Merge batches/test_batch_utils.py and the chat_completions and messages dispatch tests into the files that already existed in tests/unit. Keep the live Gemini interactions tests, the async image-fetch format test and the OpenAI embedding scorer test in tests/test_litellm since they need real network or keys. Put test_router.py under tests/unit/test_router so the existing package no longer shadows it. Delete eight tests the audit found superseded by stronger ones kept in this move. * ci: run the moved root and small-tree tests under their legacy flags Add the misc and responses-caching-types flags to unit_selection.sh and CircleCI, extend enterprise-routing and mcp-integration, and point the legacy GHA shards, Makefile, redis-compat workflow, merge smoke manifest and change classifier at the new paths. * test: make the new tests/unit directories packages tests/unit/test_package_layout.py requires every directory to carry an __init__.py, and without one the moved and retained test_litellm_responses_bridge.py modules collide on import. * test: scope the unit socket block to tests/unit in shared sessions The GHA shards collect the legacy test-path and the unit selection in one pytest session. The unit conftest's loopback-only block leaked into legacy modules that reach the network at import. The legacy conftest now lifts the restriction at collect and setup time, and the unit conftest re-applies it when collecting its own modules. * test: give the shard-script tests their own GITHUB_OUTPUT They only passed where the runner set it. The CircleCI unit job's env allowlist drops it, so the script's redirect failed there. * test: point the router and module-deletion checks at tests/unit router_code_coverage and code_qa_check_tests only searched tests/test_litellm, so the moved router tests no longer counted. The two silent-experiment tests the audit deleted were the only direct callers of those methods; they are replaced with tests that assert the forwarded shadow request and the recursion guard. --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
349 lines
12 KiB
Python
349 lines
12 KiB
Python
import fcntl
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
import shlex
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from collections.abc import Callable, Sequence
|
|
from contextlib import suppress
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
HELPER = ROOT / "scripts" / "gate_slot_lock.py"
|
|
|
|
_spec = importlib.util.spec_from_file_location("gate_slot_lock", HELPER)
|
|
assert _spec is not None and _spec.loader is not None
|
|
gate_slot_lock = importlib.util.module_from_spec(_spec)
|
|
_spec.loader.exec_module(gate_slot_lock)
|
|
|
|
START_THEN_WAIT_FOR = (
|
|
"import pathlib, sys, time\n"
|
|
"pathlib.Path(sys.argv[1]).touch()\n"
|
|
"deadline = time.monotonic() + 20\n"
|
|
"while not pathlib.Path(sys.argv[2]).exists():\n"
|
|
" if time.monotonic() > deadline:\n"
|
|
" sys.exit(3)\n"
|
|
" time.sleep(0.05)\n"
|
|
)
|
|
|
|
TOUCH_TARGET = "import pathlib, sys\npathlib.Path(sys.argv[1]).touch()\n"
|
|
|
|
RECORD_INTERVAL = (
|
|
"import sys, time\n"
|
|
"with open(sys.argv[1], 'a') as events:\n"
|
|
" events.write(f'start {time.monotonic()}\\n')\n"
|
|
" events.flush()\n"
|
|
" time.sleep(0.6)\n"
|
|
" events.write(f'end {time.monotonic()}\\n')\n"
|
|
" events.flush()\n"
|
|
)
|
|
|
|
|
|
def _env(lock_dir: Path, slots: str) -> dict[str, str]:
|
|
return {
|
|
"PATH": os.environ["PATH"],
|
|
"HOME": str(lock_dir.parent),
|
|
"LITELLM_GATE_SLOT_DIR": str(lock_dir),
|
|
"LITELLM_GATE_SLOTS": slots,
|
|
}
|
|
|
|
|
|
def _wrapped(payload: Sequence[str]) -> list[str]:
|
|
return [sys.executable, str(HELPER), sys.executable, "-c", *payload]
|
|
|
|
|
|
def _wait_until(predicate: Callable[[], bool], timeout_seconds: float) -> bool:
|
|
deadline = time.monotonic() + timeout_seconds
|
|
while time.monotonic() < deadline:
|
|
if predicate():
|
|
return True
|
|
time.sleep(0.05)
|
|
return predicate()
|
|
|
|
|
|
def _terminate_group(process: subprocess.Popen[bytes]) -> None:
|
|
with suppress(ProcessLookupError, PermissionError):
|
|
os.killpg(process.pid, signal.SIGKILL)
|
|
|
|
|
|
def _reap(process: subprocess.Popen[bytes]) -> None:
|
|
with suppress(subprocess.TimeoutExpired):
|
|
process.wait(timeout=10)
|
|
if process.poll() is None:
|
|
process.kill()
|
|
process.wait(timeout=10)
|
|
|
|
|
|
def test_six_contenders_never_exceed_two_slots_and_all_complete(tmp_path: Path) -> None:
|
|
lock_dir = tmp_path / "locks"
|
|
events_file = tmp_path / "events.log"
|
|
env = _env(lock_dir, "2")
|
|
procs = [
|
|
subprocess.Popen(
|
|
_wrapped([RECORD_INTERVAL, str(events_file)]),
|
|
env=env,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
)
|
|
for _ in range(6)
|
|
]
|
|
try:
|
|
assert [proc.wait(timeout=60) for proc in procs] == [0] * 6
|
|
finally:
|
|
for proc in procs:
|
|
if proc.poll() is None:
|
|
proc.kill()
|
|
proc.wait(timeout=10)
|
|
events = sorted(
|
|
(float(stamp), 1 if kind == "start" else -1)
|
|
for kind, stamp in (line.split() for line in events_file.read_text().splitlines())
|
|
)
|
|
assert len(events) == 12
|
|
concurrency_peaks = []
|
|
running = 0
|
|
for _, delta in events:
|
|
running += delta
|
|
concurrency_peaks.append(running)
|
|
assert max(concurrency_peaks) <= 2
|
|
|
|
|
|
def test_two_slots_admit_two_holders_at_once(tmp_path: Path) -> None:
|
|
lock_dir = tmp_path / "locks"
|
|
first_started = tmp_path / "first.started"
|
|
second_started = tmp_path / "second.started"
|
|
env = _env(lock_dir, "2")
|
|
first = subprocess.Popen(_wrapped([START_THEN_WAIT_FOR, str(first_started), str(second_started)]), env=env)
|
|
second = subprocess.Popen(_wrapped([START_THEN_WAIT_FOR, str(second_started), str(first_started)]), env=env)
|
|
assert first.wait(timeout=30) == 0
|
|
assert second.wait(timeout=30) == 0
|
|
|
|
|
|
def test_contender_beyond_capacity_queues_until_the_slot_frees(tmp_path: Path) -> None:
|
|
lock_dir = tmp_path / "locks"
|
|
holder_started = tmp_path / "holder.started"
|
|
release = tmp_path / "release"
|
|
done = tmp_path / "done"
|
|
env = _env(lock_dir, "1")
|
|
holder = subprocess.Popen(_wrapped([START_THEN_WAIT_FOR, str(holder_started), str(release)]), env=env)
|
|
try:
|
|
assert _wait_until(holder_started.exists, 10)
|
|
contender = subprocess.Popen(
|
|
_wrapped([TOUCH_TARGET, str(done)]),
|
|
env=env,
|
|
stderr=subprocess.PIPE,
|
|
)
|
|
try:
|
|
time.sleep(1.5)
|
|
assert not done.exists()
|
|
release.touch()
|
|
assert holder.wait(timeout=10) == 0
|
|
assert contender.wait(timeout=30) == 0
|
|
assert done.exists()
|
|
assert contender.stderr is not None
|
|
assert b"queueing" in contender.stderr.read()
|
|
finally:
|
|
release.touch()
|
|
_reap(contender)
|
|
finally:
|
|
release.touch()
|
|
_reap(holder)
|
|
|
|
|
|
def test_nested_wrapping_reenters_instead_of_deadlocking(tmp_path: Path) -> None:
|
|
lock_dir = tmp_path / "locks"
|
|
nested = [
|
|
sys.executable,
|
|
str(HELPER),
|
|
sys.executable,
|
|
str(HELPER),
|
|
sys.executable,
|
|
"-c",
|
|
"print('nested ok')",
|
|
]
|
|
proc = subprocess.Popen(
|
|
nested,
|
|
env=_env(lock_dir, "1"),
|
|
stdout=subprocess.PIPE,
|
|
start_new_session=True,
|
|
)
|
|
try:
|
|
stdout, _ = proc.communicate(timeout=20)
|
|
except subprocess.TimeoutExpired:
|
|
_terminate_group(proc)
|
|
pytest.fail("nested gate_slot_lock invocations deadlocked")
|
|
assert proc.returncode == 0
|
|
assert b"nested ok" in stdout
|
|
|
|
|
|
def test_wrapped_command_exit_code_is_propagated(tmp_path: Path) -> None:
|
|
proc = subprocess.run(
|
|
[sys.executable, str(HELPER), sys.executable, "-c", "raise SystemExit(7)"],
|
|
env=_env(tmp_path / "locks", "2"),
|
|
)
|
|
assert proc.returncode == 7
|
|
|
|
|
|
def test_missing_command_exits_127_and_no_command_exits_2(tmp_path: Path) -> None:
|
|
env = _env(tmp_path / "locks", "2")
|
|
missing = subprocess.run(
|
|
[sys.executable, str(HELPER), str(tmp_path / "no-such-binary")],
|
|
env=env,
|
|
capture_output=True,
|
|
)
|
|
assert missing.returncode == 127
|
|
bare = subprocess.run([sys.executable, str(HELPER)], env=env, capture_output=True)
|
|
assert bare.returncode == 2
|
|
|
|
|
|
def test_wrapped_command_killed_by_signal_maps_to_128_plus_signal(tmp_path: Path) -> None:
|
|
proc = subprocess.run(
|
|
_wrapped(["import os, signal\nos.kill(os.getpid(), signal.SIGTERM)\n"]),
|
|
env=_env(tmp_path / "locks", "2"),
|
|
)
|
|
assert proc.returncode == 128 + signal.SIGTERM
|
|
|
|
|
|
def test_unusable_lock_dir_fails_open_and_still_runs_the_command(tmp_path: Path) -> None:
|
|
blocker = tmp_path / "blocker"
|
|
blocker.write_text("")
|
|
done = tmp_path / "done"
|
|
proc = subprocess.run(
|
|
_wrapped([TOUCH_TARGET, str(done)]),
|
|
env=_env(blocker / "locks", "2"),
|
|
capture_output=True,
|
|
)
|
|
assert proc.returncode == 0
|
|
assert done.exists()
|
|
assert b"running unlocked" in proc.stderr
|
|
|
|
|
|
def test_zero_slots_disables_locking_entirely(tmp_path: Path) -> None:
|
|
lock_dir = tmp_path / "locks"
|
|
done = tmp_path / "done"
|
|
proc = subprocess.run(
|
|
_wrapped([TOUCH_TARGET, str(done)]),
|
|
env=_env(lock_dir, "0"),
|
|
)
|
|
assert proc.returncode == 0
|
|
assert done.exists()
|
|
assert not lock_dir.exists()
|
|
|
|
|
|
def test_non_integer_slot_count_warns_and_falls_back_to_default(tmp_path: Path) -> None:
|
|
proc = subprocess.run(
|
|
[sys.executable, str(HELPER), sys.executable, "-c", "print('ran')"],
|
|
env=_env(tmp_path / "locks", "lots"),
|
|
capture_output=True,
|
|
)
|
|
assert proc.returncode == 0
|
|
assert b"ran" in proc.stdout
|
|
assert b"LITELLM_GATE_SLOTS" in proc.stderr
|
|
|
|
|
|
def test_killed_holder_releases_its_slot_for_the_next_contender(tmp_path: Path) -> None:
|
|
lock_dir = tmp_path / "locks"
|
|
holder_started = tmp_path / "holder.started"
|
|
never = tmp_path / "never"
|
|
env = _env(lock_dir, "1")
|
|
holder = subprocess.Popen(
|
|
_wrapped([START_THEN_WAIT_FOR, str(holder_started), str(never)]),
|
|
env=env,
|
|
start_new_session=True,
|
|
)
|
|
try:
|
|
assert _wait_until(holder_started.exists, 10)
|
|
finally:
|
|
_terminate_group(holder)
|
|
holder.wait(timeout=10)
|
|
after = subprocess.run(
|
|
[sys.executable, str(HELPER), sys.executable, "-c", "print('freed')"],
|
|
env=env,
|
|
capture_output=True,
|
|
timeout=20,
|
|
)
|
|
assert after.returncode == 0
|
|
assert b"freed" in after.stdout
|
|
|
|
|
|
def test_acquire_slot_holds_marks_and_releases_in_process(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
lock_dir = tmp_path / "locks"
|
|
monkeypatch.setenv("LITELLM_GATE_SLOT_HELD", "")
|
|
monkeypatch.setenv("LITELLM_GATE_SLOT_DIR", str(lock_dir))
|
|
monkeypatch.setenv("LITELLM_GATE_SLOTS", "1")
|
|
handle = gate_slot_lock.acquire_slot()
|
|
assert handle is not None
|
|
assert os.environ["LITELLM_GATE_SLOT_HELD"] == "1"
|
|
assert gate_slot_lock.acquire_slot() is None
|
|
with (lock_dir / "slot-0.lock").open("wb") as probe:
|
|
with pytest.raises(BlockingIOError):
|
|
fcntl.flock(probe, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
handle.close()
|
|
fcntl.flock(probe, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
fcntl.flock(probe, fcntl.LOCK_UN)
|
|
|
|
|
|
def test_held_slot_context_manager_releases_on_exit(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
lock_dir = tmp_path / "locks"
|
|
monkeypatch.setenv("LITELLM_GATE_SLOT_HELD", "")
|
|
monkeypatch.setenv("LITELLM_GATE_SLOT_DIR", str(lock_dir))
|
|
monkeypatch.setenv("LITELLM_GATE_SLOTS", "1")
|
|
with gate_slot_lock.held_slot():
|
|
assert os.environ["LITELLM_GATE_SLOT_HELD"] == "1"
|
|
with (lock_dir / "slot-0.lock").open("wb") as probe:
|
|
with pytest.raises(BlockingIOError):
|
|
fcntl.flock(probe, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
assert not os.environ.get("LITELLM_GATE_SLOT_HELD")
|
|
with (lock_dir / "slot-0.lock").open("wb") as probe:
|
|
fcntl.flock(probe, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
fcntl.flock(probe, fcntl.LOCK_UN)
|
|
|
|
|
|
def test_direct_make_lint_takes_a_slot_before_any_setup(tmp_path: Path) -> None:
|
|
lock_dir = tmp_path / "locks"
|
|
lock_dir.mkdir()
|
|
events_file = tmp_path / "setup.jsonl"
|
|
stderr_file = tmp_path / "make.stderr"
|
|
probe = tmp_path / "probe.py"
|
|
probe.write_text(
|
|
"import fcntl, json, os, pathlib, sys\n"
|
|
"with (pathlib.Path(os.environ['LITELLM_GATE_SLOT_DIR']) / 'slot-0.lock').open('wb') as slot:\n"
|
|
" try:\n"
|
|
" fcntl.flock(slot, fcntl.LOCK_EX | fcntl.LOCK_NB)\n"
|
|
" locked = False\n"
|
|
" except BlockingIOError:\n"
|
|
" locked = True\n"
|
|
"with open(os.environ['EVENTS_FILE'], 'a') as events:\n"
|
|
" events.write(json.dumps({'phase': sys.argv[1], 'locked': locked}) + '\\n')\n"
|
|
"if sys.argv[1] == 'base':\n"
|
|
" print('HEAD')\n"
|
|
)
|
|
(tmp_path / "Makefile").write_text((ROOT / "Makefile").read_text())
|
|
command = [
|
|
"make", "-o", "lint-checks", "lint", "MAKE=make -o lint-checks",
|
|
f"GATE_SLOT_LOCK={shlex.join([sys.executable, str(HELPER)])}",
|
|
f"UV={shlex.join([sys.executable, str(probe), 'setup'])}",
|
|
f"UV_RUN={shlex.join([sys.executable, str(probe), 'setup'])}",
|
|
f"RESOLVE_BASE={shlex.join([sys.executable, str(probe), 'base'])}",
|
|
]
|
|
with (lock_dir / "slot-0.lock").open("wb") as held, stderr_file.open("wb") as stderr:
|
|
fcntl.flock(held, fcntl.LOCK_EX)
|
|
process = subprocess.Popen(
|
|
command, cwd=tmp_path, stdout=subprocess.DEVNULL, stderr=stderr,
|
|
env={**_env(lock_dir, "1"), "EVENTS_FILE": str(events_file)},
|
|
)
|
|
try:
|
|
assert _wait_until(lambda: "queueing" in stderr_file.read_text(), 10)
|
|
assert not events_file.exists()
|
|
fcntl.flock(held, fcntl.LOCK_UN)
|
|
assert process.wait(timeout=30) == 0, stderr_file.read_text()
|
|
finally:
|
|
fcntl.flock(held, fcntl.LOCK_UN)
|
|
_reap(process)
|
|
events = tuple(json.loads(line) for line in events_file.read_text().splitlines())
|
|
assert {event["phase"] for event in events} == {"setup", "base"}
|
|
assert all(event["locked"] for event in events)
|