mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-28 01:32:17 +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>
172 lines
5.7 KiB
Python
172 lines
5.7 KiB
Python
"""Tests for .github/scripts/e2e_egress_sentinel.py.
|
|
|
|
The replay lane's zero-egress proof is only as good as this sentinel: it pins the
|
|
provider hosts to a local sink and counts every connection that reaches them, so
|
|
a single escaped provider call turns the run red. The contract locked in here is
|
|
that the counter counts (each accepted connection is exactly one recorded hit),
|
|
that ``assert-empty`` is the pass/fail gate around that count, and that the hosts
|
|
file it edits is always handed back exactly as it was found.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Final
|
|
|
|
_REPO_ROOT: Final = Path(__file__).resolve().parents[2]
|
|
_MODULE_PATH: Final = _REPO_ROOT / ".github" / "scripts" / "e2e_egress_sentinel.py"
|
|
_spec: Final = importlib.util.spec_from_file_location("e2e_egress_sentinel", _MODULE_PATH)
|
|
sentinel: Final = importlib.util.module_from_spec(_spec)
|
|
sys.modules[_spec.name] = sentinel # @dataclass(slots=True) rebuilds via sys.modules
|
|
_spec.loader.exec_module(sentinel)
|
|
|
|
|
|
def _hit_lines(hits_file: Path) -> list[str]:
|
|
if not hits_file.exists():
|
|
return []
|
|
return [line for line in hits_file.read_text(encoding="utf-8").splitlines() if line.strip()]
|
|
|
|
|
|
def test_pin_block_lists_every_host_against_the_sink():
|
|
block = sentinel._pin_block("127.0.0.1", ("api.openai.com", "api.anthropic.com"))
|
|
assert "127.0.0.1\tapi.openai.com" in block
|
|
assert "127.0.0.1\tapi.anthropic.com" in block
|
|
assert sentinel._BLOCK_BEGIN in block and sentinel._BLOCK_END in block
|
|
|
|
|
|
def test_install_and_restore_round_trips_an_existing_hosts_file(tmp_path):
|
|
hosts = tmp_path / "hosts"
|
|
original = "127.0.0.1\tlocalhost\n255.255.255.255\tbroadcasthost\n"
|
|
hosts.write_text(original, encoding="utf-8")
|
|
|
|
saved = sentinel._install_pins(hosts, "127.0.0.1", ("api.openai.com",))
|
|
assert saved == original.encode()
|
|
assert "api.openai.com" in hosts.read_text(encoding="utf-8")
|
|
|
|
sentinel._restore_pins(hosts, saved)
|
|
assert hosts.read_text(encoding="utf-8") == original
|
|
|
|
|
|
def test_install_on_a_missing_hosts_file_creates_then_restore_empties(tmp_path):
|
|
hosts = tmp_path / "hosts"
|
|
saved = sentinel._install_pins(hosts, "127.0.0.1", ("api.anthropic.com",))
|
|
assert saved == b""
|
|
assert "api.anthropic.com" in hosts.read_text(encoding="utf-8")
|
|
|
|
sentinel._restore_pins(hosts, saved)
|
|
assert hosts.read_text(encoding="utf-8") == ""
|
|
|
|
|
|
def test_assert_empty_passes_when_no_calls(tmp_path):
|
|
absent = tmp_path / "absent.jsonl"
|
|
assert sentinel.assert_empty(absent) == 0
|
|
|
|
empty = tmp_path / "empty.jsonl"
|
|
empty.write_text("\n \n", encoding="utf-8")
|
|
assert sentinel.assert_empty(empty) == 0
|
|
|
|
|
|
def test_assert_empty_fails_when_calls_recorded(tmp_path):
|
|
hits = tmp_path / "hits.jsonl"
|
|
hits.write_text('{"port": 443, "peer": ["127.0.0.1", 5]}\n', encoding="utf-8")
|
|
assert sentinel.assert_empty(hits) == 1
|
|
|
|
|
|
def test_accept_loop_records_exactly_one_hit_per_connection(tmp_path):
|
|
hits_file = tmp_path / "hits.jsonl"
|
|
hits_file.write_text("", encoding="utf-8")
|
|
listener = sentinel._bind("127.0.0.1", 0)
|
|
port = listener.getsockname()[1]
|
|
stop = threading.Event()
|
|
hits = sentinel._HitLog(path=hits_file, _lock=threading.Lock())
|
|
worker = threading.Thread(target=sentinel._serve_socket, args=(listener, port, hits, stop), daemon=True)
|
|
worker.start()
|
|
|
|
try:
|
|
for _ in range(3):
|
|
conn = socket.create_connection(("127.0.0.1", port), timeout=2)
|
|
conn.close()
|
|
deadline = time.time() + 3
|
|
while time.time() < deadline and len(_hit_lines(hits_file)) < 3:
|
|
time.sleep(0.02)
|
|
finally:
|
|
stop.set()
|
|
listener.close()
|
|
worker.join(timeout=3)
|
|
|
|
assert len(_hit_lines(hits_file)) == 3
|
|
|
|
|
|
def test_serve_end_to_end_pins_counts_and_restores(tmp_path):
|
|
hosts = tmp_path / "hosts"
|
|
hosts.write_text("127.0.0.1\tlocalhost\n", encoding="utf-8")
|
|
hits = tmp_path / "hits.jsonl"
|
|
ready = tmp_path / "ready"
|
|
pidf = tmp_path / "pid"
|
|
port = _free_port()
|
|
|
|
proc = subprocess.Popen(
|
|
[
|
|
sys.executable,
|
|
str(_MODULE_PATH),
|
|
"serve",
|
|
"--host",
|
|
"api.openai.com",
|
|
"--host",
|
|
"api.anthropic.com",
|
|
"--sink-address",
|
|
"127.0.0.1",
|
|
"--port",
|
|
str(port),
|
|
"--hits-file",
|
|
str(hits),
|
|
"--ready-file",
|
|
str(ready),
|
|
"--pid-file",
|
|
str(pidf),
|
|
"--hosts-file",
|
|
str(hosts),
|
|
],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
text=True,
|
|
)
|
|
try:
|
|
_wait_for(lambda: ready.exists(), timeout=10)
|
|
pinned = hosts.read_text(encoding="utf-8")
|
|
assert "api.openai.com" in pinned and "api.anthropic.com" in pinned
|
|
|
|
for _ in range(2):
|
|
conn = socket.create_connection(("127.0.0.1", port), timeout=2)
|
|
conn.close()
|
|
_wait_for(lambda: len(_hit_lines(hits)) >= 2, timeout=5)
|
|
assert sentinel.assert_empty(hits) == 1
|
|
finally:
|
|
proc.terminate()
|
|
proc.wait(timeout=10)
|
|
|
|
assert hosts.read_text(encoding="utf-8") == "127.0.0.1\tlocalhost\n"
|
|
assert not ready.exists()
|
|
|
|
|
|
def _free_port() -> int:
|
|
probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
probe.bind(("127.0.0.1", 0))
|
|
port = probe.getsockname()[1]
|
|
probe.close()
|
|
return port
|
|
|
|
|
|
def _wait_for(predicate, *, timeout: float) -> None:
|
|
deadline = time.time() + timeout
|
|
while time.time() < deadline:
|
|
if predicate():
|
|
return
|
|
time.sleep(0.05)
|
|
raise AssertionError("condition never became true within the timeout")
|