mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-05 02:41:56 +00:00
A test that fails (incl. all the failing retries before a passing one) can otherwise overwrite a known-good cassette with a 'bad luck' recording. Tests like test_prompt_caching, which assert on provider state across two calls, can produce a 200 response that semantically fails the assertion — the 2xx filter doesn't catch this because the HTTP layer is fine. - pytest_runtest_makereport hook attaches each phase report to the pytest item. - _vcr_outcome_gate fixture (combining the verbose-mode reporter) reads the call-phase outcome at teardown and informs the persister via mark_test_outcome_for_cassette before vcrpy's Cassette.__exit__ triggers save_cassette. - save_cassette consults the per-key 'did the test pass?' flag and short-circuits when False, leaving any prior good recording intact. - Defaults to passed=True when no marker is present so non-test usage of the persister still works.
212 lines
6 KiB
Python
212 lines
6 KiB
Python
# conftest.py
|
|
|
|
import asyncio
|
|
import importlib
|
|
import os
|
|
import sys
|
|
|
|
import pytest
|
|
|
|
sys.path.insert(
|
|
0, os.path.abspath("../..")
|
|
) # Adds the parent directory to the system path
|
|
|
|
import litellm # noqa: E402
|
|
|
|
from tests._vcr_redis_persister import ( # noqa: E402
|
|
filter_non_2xx_response,
|
|
format_vcr_verdict,
|
|
make_redis_persister,
|
|
mark_test_outcome_for_cassette,
|
|
patch_vcrpy_aiohttp_record_path,
|
|
vcr_verbose_enabled,
|
|
)
|
|
|
|
|
|
_FILTERED_REQUEST_HEADERS = (
|
|
"authorization",
|
|
"x-api-key",
|
|
"anthropic-api-key",
|
|
"anthropic-version",
|
|
"openai-api-key",
|
|
"azure-api-key",
|
|
"api-key",
|
|
"cookie",
|
|
"x-amz-security-token",
|
|
"x-amz-date",
|
|
"x-amz-content-sha256",
|
|
"amz-sdk-invocation-id",
|
|
"amz-sdk-request",
|
|
"x-goog-api-key",
|
|
"x-goog-user-project",
|
|
)
|
|
|
|
_FILTERED_RESPONSE_HEADERS = (
|
|
"set-cookie",
|
|
"x-request-id",
|
|
"request-id",
|
|
"cf-ray",
|
|
"anthropic-organization-id",
|
|
"openai-organization",
|
|
"x-amzn-requestid",
|
|
"x-amzn-trace-id",
|
|
"date",
|
|
)
|
|
|
|
|
|
def _scrub_response(response):
|
|
if not isinstance(response, dict):
|
|
return response
|
|
headers = response.get("headers") or {}
|
|
if isinstance(headers, dict):
|
|
for header in list(headers):
|
|
if header.lower() in _FILTERED_RESPONSE_HEADERS:
|
|
headers.pop(header, None)
|
|
return response
|
|
|
|
|
|
def _before_record_response(response):
|
|
return filter_non_2xx_response(_scrub_response(response))
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def vcr_config():
|
|
return {
|
|
"filter_headers": list(_FILTERED_REQUEST_HEADERS),
|
|
"decode_compressed_response": True,
|
|
"record_mode": "new_episodes",
|
|
"allow_playback_repeats": True,
|
|
"match_on": (
|
|
"method",
|
|
"scheme",
|
|
"host",
|
|
"port",
|
|
"path",
|
|
"query",
|
|
"body",
|
|
),
|
|
"before_record_response": _before_record_response,
|
|
}
|
|
|
|
|
|
def _vcr_disabled() -> bool:
|
|
if os.environ.get("LITELLM_VCR_DISABLE") == "1":
|
|
return True
|
|
# Cassettes live on a dedicated Redis (CASSETTE_REDIS_URL) so the cache
|
|
# isn't shared with — and accidentally flushed by — tests that exercise
|
|
# the application Redis via REDIS_URL/REDIS_HOST.
|
|
return not os.environ.get("CASSETTE_REDIS_URL")
|
|
|
|
|
|
def pytest_recording_configure(config, vcr):
|
|
if _vcr_disabled():
|
|
return
|
|
vcr.register_persister(make_redis_persister())
|
|
patch_vcrpy_aiohttp_record_path()
|
|
|
|
|
|
@pytest.hookimpl(hookwrapper=True)
|
|
def pytest_runtest_makereport(item, call):
|
|
"""Attach each phase's report to the item so fixture teardown can read it.
|
|
|
|
Used by ``_vcr_outcome_gate`` below to skip persisting cassettes for
|
|
failed test runs (incl. failed retries that pytest-rerunfailures will
|
|
re-attempt) so a "bad luck" recording can't poison future replays.
|
|
"""
|
|
outcome = yield
|
|
rep = outcome.get_result()
|
|
setattr(item, f"rep_{rep.when}", rep)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _vcr_outcome_gate(request, vcr):
|
|
"""Tell the persister whether the test that owns this cassette passed.
|
|
|
|
Runs after ``vcr`` (which yields the active Cassette). At teardown time
|
|
the call-phase report is attached to the item by the makereport hook
|
|
above, so we can mark the cassette key passed/failed before vcrpy's
|
|
Cassette.__exit__ triggers persister.save_cassette.
|
|
|
|
Also prints a per-test hit/miss verdict when LITELLM_VCR_VERBOSE=1.
|
|
"""
|
|
yield
|
|
cassette = vcr # name kept for the verbose-output line
|
|
rep_call = getattr(request.node, "rep_call", None)
|
|
test_passed = bool(rep_call and rep_call.passed)
|
|
cassette_path = getattr(cassette, "_path", None) if cassette is not None else None
|
|
if cassette_path:
|
|
mark_test_outcome_for_cassette(cassette_path, test_passed)
|
|
|
|
if not vcr_verbose_enabled():
|
|
return
|
|
verdict = format_vcr_verdict(cassette)
|
|
reporter = request.config.pluginmanager.get_plugin("terminalreporter")
|
|
line = f"{verdict} :: {request.node.nodeid}"
|
|
if reporter is not None:
|
|
reporter.write_line(line)
|
|
else: # pragma: no cover - reporter is always present in normal runs
|
|
print(line)
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def event_loop():
|
|
try:
|
|
loop = asyncio.get_running_loop()
|
|
except RuntimeError:
|
|
loop = asyncio.new_event_loop()
|
|
yield loop
|
|
loop.close()
|
|
|
|
|
|
@pytest.fixture(scope="function", autouse=True)
|
|
def setup_and_teardown():
|
|
"""
|
|
This fixture reloads litellm before every function. To speed up testing by removing callbacks being chained.
|
|
"""
|
|
curr_dir = os.getcwd() # Get the current working directory
|
|
sys.path.insert(
|
|
0, os.path.abspath("../..")
|
|
) # Adds the project directory to the system path
|
|
|
|
import litellm
|
|
from litellm import Router
|
|
|
|
importlib.reload(litellm)
|
|
|
|
try:
|
|
if hasattr(litellm, "proxy") and hasattr(litellm.proxy, "proxy_server"):
|
|
import litellm.proxy.proxy_server
|
|
|
|
importlib.reload(litellm.proxy.proxy_server)
|
|
except Exception as e:
|
|
print(f"Error reloading litellm.proxy.proxy_server: {e}")
|
|
|
|
import asyncio
|
|
|
|
loop = asyncio.get_event_loop_policy().new_event_loop()
|
|
asyncio.set_event_loop(loop)
|
|
print(litellm)
|
|
# from litellm import Router, completion, aembedding, acompletion, embedding
|
|
yield
|
|
|
|
# Teardown code (executes after the yield point)
|
|
loop.close() # Close the loop created earlier
|
|
asyncio.set_event_loop(None) # Remove the reference to the loop
|
|
|
|
|
|
def pytest_collection_modifyitems(config, items):
|
|
if not _vcr_disabled():
|
|
for item in items:
|
|
if item.get_closest_marker("vcr") is not None:
|
|
continue
|
|
item.add_marker(pytest.mark.vcr)
|
|
|
|
custom_logger_tests = [
|
|
item for item in items if "custom_logger" in item.parent.name
|
|
]
|
|
other_tests = [item for item in items if "custom_logger" not in item.parent.name]
|
|
|
|
custom_logger_tests.sort(key=lambda x: x.name)
|
|
other_tests.sort(key=lambda x: x.name)
|
|
|
|
items[:] = custom_logger_tests + other_tests
|