ci: remove CircleCI (#50950)

This commit is contained in:
Yassin Kortam 2026-11-11 15:38:27 -08:00 • committed by GitHub
parent 2c44f26288
commit a6ce922f07
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 294 additions and 2663 deletions

View file

@ -1,7 +0,0 @@
# .circleci
The `integration` workflow on `main` runs from a scheduled pipeline in the CircleCI project settings, cron `9 0-8,14-23 * * *` (UTC) on `main` with no pipeline parameters. It has no `triggers:` block in this file because a workflow with one runs only on its schedule, so the API pipelines that set `run_migration_tests` or `routing_parity_base` would stop running it. If main stops getting integration runs, check that the schedule still exists and recreate it with these values
Jobs clone with the `checkout_over_https` command, never the built-in `checkout` step, because CircleCI falls back to the project's SSH checkout key without its GitHub App access, GitHub rejects that key, and the public repo needs no key. It fetches the job's commit with full history and no file contents (`--filter=blob:none`), like the built-in step did, so `scripts/path_filter.sh` can find a merge base
A step that checks out files from another commit fetches that commit with `--no-filter`, as `routing_parity` does, because the clone has no file contents and git would otherwise download each missing file in its own request, which took 210 to 896 seconds for the routing parity base

File diff suppressed because it is too large Load diff

View file

@ -1,116 +0,0 @@
from __future__ import annotations
import json
import os
import re
import subprocess
import sys
from pathlib import Path
from typing import Final
from xml.etree import ElementTree
SUITES: Final = {
"startup": (("test_startup.py",), 13),
"recovery": (("test_recovery.py",), 15),
"legacy": (("test_legacy.py", "test_pooling.py"), 11),
"upgrade": (("test_upgrade.py", "test_rolling_upgrade.py"), 5),
"shaped": (("test_shaped_database.py",), 1),
}
def successful_junit(path: Path, expected: int, exit_code: int) -> bool:
if exit_code != 0 or not path.is_file():
return False
try:
root: Final = ElementTree.parse(path).getroot()
except ElementTree.ParseError:
return False
cases: Final = tuple(root.iter("testcase"))
identities: Final = frozenset((case.get("classname"), case.get("name")) for case in cases)
return len(cases) == len(identities) == expected and all(
not any(case.find(tag) is not None for tag in ("failure", "error", "skipped")) for case in cases
)
def output(*command: str) -> str:
return subprocess.check_output(command, text=True, timeout=90).strip()
def record_image() -> None:
source: Final = output("git", "rev-parse", "HEAD")
image: Final = output("docker", "image", "inspect", "litellm-docker-database:ci", "--format", "{{.Id}}")
revision: Final = output(
"docker",
"image",
"inspect",
"litellm-docker-database:ci",
"--format",
'{{index .Config.Labels "org.opencontainers.image.revision"}}',
)
assert re.fullmatch(r"[0-9a-f]{40}", source), "Invalid source revision"
assert revision == source, "Candidate image revision differs from the tested source"
Path("migration-image.json").write_text(
json.dumps(
{
"source_sha": source,
"image_id": image,
"candidate_image": os.environ.get("MIGRATION_CANDIDATE_IMAGE", ""),
}
)
)
def main() -> int:
suite: Final = os.environ["MIGRATION_TEST_SUITE"]
files, expected = SUITES[suite]
metadata: Final = json.loads(Path("migration-image.json").read_text())
assert metadata["source_sha"] == output("git", "rev-parse", "HEAD"), "Image and test source revisions differ"
assert metadata["image_id"] == output(
"docker", "image", "inspect", os.environ["LITELLM_MIGRATION_TEST_IMAGE"], "--format", "{{.Id}}"
), "Loaded image differs from the build output"
assert metadata["candidate_image"] == os.environ.get("MIGRATION_CANDIDATE_IMAGE", ""), "Wrong release candidate"
destination: Final = Path(os.environ["MIGRATION_TEST_OUTPUT"])
junit: Final = destination / "junit" / "results.xml"
junit.parent.mkdir(parents=True, exist_ok=True)
result: Final = subprocess.run(
(
sys.executable,
"-m",
"pytest",
*(f"tests/e2e/migrations/{name}" for name in files),
"-vv",
"--tb=short",
"--durations=10",
f"--junitxml={junit}",
"-o",
"addopts=",
"--reruns=0",
),
check=False,
timeout=1200,
)
passed: Final = successful_junit(junit, expected, result.returncode)
(destination / "verdict.json").write_text(
json.dumps(
{
**metadata,
"suite": suite,
"baseline_image": os.environ.get("LITELLM_MIGRATION_BASELINE_IMAGE", ""),
"expected_cases": expected,
"passed": passed,
"pytest_exit_code": result.returncode,
"test_revision": metadata["source_sha"],
"workflow_id": os.environ.get("CIRCLE_WORKFLOW_ID", ""),
"job_number": os.environ.get("CIRCLE_BUILD_NUM", ""),
},
indent=2,
)
)
return 0 if passed else 1
if __name__ == "__main__":
if sys.argv[1:] == ["record-image"]:
record_image()
else:
raise SystemExit(main())

View file

@ -1,6 +1,5 @@
docs
litellm/proxy/discovery_endpoints/skill_scanner_adapter
.circleci
.github
tests
.git

5
.github/AGENTS.md vendored Normal file
View file

@ -0,0 +1,5 @@
# CI
Integration groups and replica runs remain in `workflows/test-integration.yml`. Do not duplicate them in another workflow.
CI helpers used by existing workflows live in `scripts/ci/`. Jobs that need merge bases check out full history. Removing a CI job does not count its tests as covered. List tests that no remaining job invokes explicitly in `ci-coverage-allowlist.yml`.

View file

@ -1,6 +1,6 @@
name: "Detect relevant changes"
description: >-
Classify the pull request's changed files with .circleci/scripts/classify_changes.sh
Classify the pull request's changed files with .github/scripts/ci/classify_changes.sh
and expose decision=run|skip for one category. backend means anything outside ui/,
docs/ and markdown; ui means the dashboard sources alone. decision=skip lets callers
short-circuit expensive steps while the job still completes successfully and satisfies

View file

@ -4,6 +4,16 @@ description: >-
by a job nor listed here, so every entry below is a decision on the record.
test_paths:
- reason: ran only in CircleCI, removed
paths:
- tests/e2e/migrations/test_legacy.py
- tests/e2e/migrations/test_pooling.py
- tests/e2e/migrations/test_recovery.py
- tests/e2e/migrations/test_rolling_upgrade.py
- tests/e2e/migrations/test_shaped_database.py
- tests/e2e/migrations/test_startup.py
- tests/e2e/migrations/test_upgrade.py
- tests/integration/database/test_roi_observed.py
- reason: >-
litellm.agent() end-to-end suite. It drives the real claude, codex, opencode and pi CLIs and
deepagents against a live LiteLLM AI Gateway, so it needs those binaries on PATH plus
@ -19,7 +29,7 @@ test_paths:
- tests/rust-python-harness
- reason: >-
Owned by the open pull request that moves the Rust/Python parity checks (#45135). It ran in
the CircleCI build_and_test job that globbed tests/test_*.py, which this migration deletes,
the retired build_and_test job that globbed tests/test_*.py,
and that pull request gives it a new home
paths:
- tests/test_rust_python_harness.py

View file

@ -10,6 +10,7 @@ import warnings
from collections.abc import Callable, Iterable, Mapping, Sequence
from dataclasses import dataclass
from functools import lru_cache
from itertools import chain
from types import MappingProxyType
from typing import Final
@ -17,7 +18,6 @@ import yaml
REPO_ROOT = pathlib.Path(__file__).resolve().parents[2]
WORKFLOW_DIR = REPO_ROOT / ".github" / "workflows"
CIRCLECI_CONFIG = REPO_ROOT / ".circleci" / "config.yml"
ALLOWLIST_FILE = REPO_ROOT / ".github" / "ci-coverage-allowlist.yml"
TESTS_ROOT = REPO_ROOT / "tests"
@ -25,14 +25,18 @@ ALLOWLIST_KEYS = frozenset({"description", "test_paths", "dockerfiles"})
PATH_FILTER_KEYS = frozenset({"paths", "paths-ignore"})
TEST_PATH_KEYS = frozenset({"test-path", "test-paths", "test_path"})
DOCKERFILE_INPUT_KEYS = frozenset({"file", "dockerfile"})
TEST_RUNNER_RE = re.compile(r"\bpytest\b|\bcircleci tests\b|\bhelm unittest\b|\bplaywright test\b|\bpython[0-9.]*\s")
TEST_RUNNER_RE = re.compile(r"\bpytest\b|\bhelm unittest\b|\bplaywright test\b|\bpython[0-9.]*\s")
IMAGE_BUILD_RE = re.compile(r"\bdocker\s+(?:buildx\s+)?build\b")
TEST_TOKEN_RE = re.compile(r"tests/[A-Za-z0-9_./*?-]+")
IGNORE_ARG_RE: Final = re.compile(r"--ignore(?:-glob)?[= ](\S+)")
DOCKERFILE_TOKEN_RE = re.compile(r"[A-Za-z0-9_./-]*Dockerfile[A-Za-z0-9_.-]*")
COMMENT_RE = re.compile(r"^\s*#.*$", re.MULTILINE)
GLOB_CHARS = frozenset("*?")
DEDICATED_INTEGRATION_WORKFLOWS: Final = MappingProxyType({"image-scan.yml": "tests/integration/database/image/"})
DEDICATED_INTEGRATION_WORKFLOWS: Final = MappingProxyType(
{
"image-scan.yml": "tests/integration/database/image/",
}
)
# Trees whose jobs are sharded with no catch-all bucket, so every child that holds
# tests has to be named by some shard or it runs nowhere. A child listed here is
@ -115,9 +119,7 @@ def _scalars(node: object, key: str) -> tuple[Scalar, ...]:
def _config_files() -> tuple[pathlib.Path, ...]:
workflows = tuple(sorted(path for path in WORKFLOW_DIR.iterdir() if path.suffix in (".yml", ".yaml")))
circleci = (CIRCLECI_CONFIG,) if CIRCLECI_CONFIG.is_file() else ()
return workflows + circleci
return tuple(sorted(path for path in WORKFLOW_DIR.iterdir() if path.suffix in (".yml", ".yaml")))
def _all_scalars() -> tuple[Scalar, ...]:
@ -243,7 +245,6 @@ def _describe(paths: tuple[str, ...]) -> str:
return f"{len(paths)} test file(s) invoked by no job: {names}{suffix}"
GLOB_CALL_RE = re.compile(r'circleci tests glob "([^"]+)"')
KEYWORD_RE = re.compile(r"-k\s+\\?[\"']([^\"'\\]+)")
@ -316,16 +317,14 @@ def _keyword_terms(
def _slices() -> tuple[Slice, ...]:
if not CIRCLECI_CONFIG.exists():
return ()
test_files: Final = frozenset(_test_files())
jobs: Final = yaml.safe_load(CIRCLECI_CONFIG.read_text()).get("jobs", {})
return tuple(
Slice(job=job, globs=globs, named=named, required=required, excluded=excluded, understood=understood)
for job, body in jobs.items()
Slice(job=f"{path.name}:{job}", globs=globs, named=named, required=required, excluded=excluded, understood=understood)
for path in _config_files()
for job, body in yaml.safe_load(path.read_text()).get("jobs", {}).items()
for text in ("\n".join(_strings(body)),)
if "pytest" in text
for globs in (tuple(GLOB_CALL_RE.findall(text)),)
for globs in (tuple(token for token in TEST_TOKEN_RE.findall(text) if GLOB_CHARS & set(token)),)
for named in (frozenset(TEST_TOKEN_RE.findall(text)) & test_files,)
for required, excluded, understood in (
_keyword_terms(tuple(KEYWORD_RE.findall(text)), attributable=len(globs) < 2),
@ -352,16 +351,11 @@ def _matchable_names(relative_path: str) -> frozenset[str]:
def _workflow_named_tokens() -> frozenset[str]:
"""Test tokens a GitHub Actions job names directly.
A CircleCI `-k` that deselects a file no longer means the file runs nowhere once a
workflow names it, so the slice check has to credit those the same way the census does.
"""
return _invoked_test_tokens(
scalar
for path in _config_files()
if path != CIRCLECI_CONFIG
for scalar in _scalars(yaml.safe_load(path.read_text(encoding="utf-8")), path.name)
if not KEYWORD_RE.search(scalar.value)
)
@ -566,13 +560,28 @@ def _owns(workflow: str, relative_path: str) -> bool:
return owned_root is not None and relative_path.startswith(owned_root)
def _expression_suites(value: str) -> tuple[str, ...]:
suites: Final = chain.from_iterable(map(json.loads, re.findall(r"'(\[[^']*\])'", value)))
return tuple(suite for suite in suites if isinstance(suite, str) and "{" not in suite)
def _matrix_suites(value: object) -> tuple[str, ...]:
if isinstance(value, list):
return tuple(suite for suite in value if isinstance(suite, str))
if isinstance(value, str):
return _expression_suites(value)
return ()
def _tokens_outside_owner(relative_path: str, workflow_tokens: Mapping[str, frozenset[str]]) -> frozenset[str]:
return frozenset[str]().union(
*(tokens for workflow, tokens in workflow_tokens.items() if not _owns(workflow, relative_path))
)
def _integration_ownership(repo_root: pathlib.Path = REPO_ROOT) -> tuple[frozenset[str], tuple[Finding, ...]]:
def _integration_ownership(
repo_root: pathlib.Path = REPO_ROOT, *, allowlist: Allowlist | None = None
) -> tuple[frozenset[str], tuple[Finding, ...]]:
runner: Final = repo_root / "tests/integration/run.py"
if not runner.exists():
return frozenset(), ()
@ -589,26 +598,19 @@ def _integration_ownership(repo_root: pathlib.Path = REPO_ROOT) -> tuple[frozens
browser_manifest: Final = repo_root / "tests/e2e/ui/tests/integrationCritical/expected.json"
browser_nodes: Final = json.loads(browser_manifest.read_text()) if browser_manifest.exists() else ()
browser_paths: Final = frozenset(node.split("::", 1)[0] for node in browser_nodes)
circle_path: Final = repo_root / ".circleci/config.yml"
circle: Final = yaml.safe_load(circle_path.read_text()) if circle_path.exists() else {}
circle_test_path_tokens: Final = _invoked_test_tokens(
scalar for scalar in _scalars(circle, "config.yml") if scalar.key == "test_path"
)
steps: Final = circle.get("jobs", {}).get("integration_contracts", {}).get("steps", ())
integration_path: Final = repo_root / ".github/workflows/test-integration.yml"
integration: Final = yaml.safe_load(integration_path.read_text()) if integration_path.exists() else {}
integration_jobs: Final = integration.get("jobs", {})
invoked: Final = any(
".circleci/scripts/run_integration.sh" in scalar.value
for scalar in _scalars(steps, "integration_contracts")
if scalar.key == "command"
".github/scripts/ci/run_integration.sh" in scalar.value
for scalar in _scalars(integration_jobs, "integration")
if scalar.key == "run"
)
scheduled: Final = frozenset(
suite
for job in circle.get("workflows", {}).get("integration", {}).get("jobs", ())
if isinstance(job, dict) and "integration_contracts" in job
for suite in job["integration_contracts"]
.get("matrix", {})
.get("parameters", {})
.get("suite", (job["integration_contracts"].get("suite"),))
if isinstance(suite, str)
for body in integration_jobs.values()
if any(".github/scripts/ci/run_integration.sh" in scalar.value for scalar in _scalars(body, "integration"))
for suite in _matrix_suites(body.get("strategy", {}).get("matrix", {}).get("suite", ()))
)
required: Final = (frozenset({"browser"}) if browser_paths else frozenset()) | frozenset(
group
@ -630,19 +632,27 @@ def _integration_ownership(repo_root: pathlib.Path = REPO_ROOT) -> tuple[frozens
}
)
gha_tokens: Final = frozenset[str]().union(*workflow_tokens.values())
circle_owned: Final = (
invoked_github_files: Final = frozenset(
path for path in github_files if any(_token_covers(token, path) for token in gha_tokens)
)
grouped: Final = (
frozenset(str(path.relative_to(repo_root)) for path in integration_root.rglob("test_*.py")) - github_files
)
findings: Final = (
tuple(
Finding(path, "integration file outside GITHUB_FILES is selected by GitHub Actions")
for path in sorted(circle_owned)
if any(_token_covers(token, path) for token in gha_tokens)
for path in sorted(grouped)
if any(
_token_covers(token, path)
for workflow, tokens in workflow_tokens.items()
if workflow != "test-integration.yml"
for token in tokens
)
)
+ tuple(
Finding(path, "GitHub-owned integration contract has no invoking job")
for path in sorted(github_files)
if not any(_token_covers(token, path) for token in gha_tokens | circle_test_path_tokens)
if path not in invoked_github_files and not (allowlist is not None and allowlist.covers_test(path))
)
+ tuple(
Finding(path, "GitHub-owned integration file is missing")
@ -658,6 +668,7 @@ def _integration_ownership(repo_root: pathlib.Path = REPO_ROOT) -> tuple[frozens
browser_commands: Final = tuple(
scalar.value
for path in (repo_root / ".github/workflows").glob("*.y*ml")
if path.name != "test-integration.yml"
for scalar in _scalars(yaml.safe_load(path.read_text()), path.name)
if scalar.key in {"run", "command"}
)
@ -690,14 +701,14 @@ def _integration_ownership(repo_root: pathlib.Path = REPO_ROOT) -> tuple[frozens
else ()
)
group_findings: Final = tuple(
Finding(group, "canonical integration group is not scheduled by CircleCI")
Finding(group, "canonical integration group is not scheduled by GitHub Actions")
for group in sorted(required - scheduled)
) + tuple(Finding(path, "canonical node must have exactly one integration group") for path in sorted(ungrouped))
if not paths or not invoked or not scheduled:
return frozenset(), findings + (
Finding(str(runner.relative_to(repo_root)), "dedicated CircleCI runner is missing"),
Finding(str(runner.relative_to(repo_root)), "dedicated GitHub Actions runner is missing"),
)
return paths | browser_paths | github_files, findings + group_findings + browser_findings + exclusion_findings
return paths | browser_paths | invoked_github_files, findings + group_findings + browser_findings + exclusion_findings
def main() -> int:
@ -709,7 +720,7 @@ def main() -> int:
allowlist = _load_allowlist()
scalars = _all_scalars()
integration_paths, ownership_findings = _integration_ownership()
integration_paths, ownership_findings = _integration_ownership(allowlist=allowlist)
test_findings = _uncovered_tests(
allowlist,
_invoked_selections(scalars) + (Selection(included=integration_paths, ignored=frozenset()),),
@ -731,8 +742,8 @@ def main() -> int:
)
if ownership_findings:
browser_remedy = (
" CircleCI's browser group owns the integrationCritical Playwright contracts, so no GitHub workflow may "
"select them and the default Playwright config must exclude them."
" The browser group in test-integration.yml owns the integrationCritical Playwright contracts. "
"Other workflows and the default Playwright config must exclude them."
if any("browser" in finding.detail or "Playwright" in finding.detail for finding in ownership_findings)
else ""
)

View file

@ -14,22 +14,22 @@ outside_cost_map_set=false
while IFS= read -r file || [ -n "$file" ]; do
[ -n "$file" ] || continue
case "$file" in
litellm/_redis.py | litellm/_redis_credential_provider.py | litellm/caching/redis_cache.py | litellm/caching/evicted_client_closer.py | tests/unit/test_redis.py | tests/integration/sdk/test_redis_cluster_iam_auth.py | tests/unit/caching/test_redis_connection_pool.py | tests/unit/caching/test_redis_cluster_cache.py | tests/unit/caching/test_evicted_client_closer.py | .github/workflows/test-unit.yml | .github/actions/detect-changes/* | .github/scripts/detect_changes.sh | .circleci/scripts/classify_changes.sh | tests/integration/run.py | pyproject.toml | uv.lock)
litellm/_redis.py | litellm/_redis_credential_provider.py | litellm/caching/redis_cache.py | litellm/caching/evicted_client_closer.py | tests/unit/test_redis.py | tests/integration/sdk/test_redis_cluster_iam_auth.py | tests/unit/caching/test_redis_connection_pool.py | tests/unit/caching/test_redis_cluster_cache.py | tests/unit/caching/test_evicted_client_closer.py | .github/workflows/test-unit.yml | .github/actions/detect-changes/* | .github/scripts/detect_changes.sh | .github/scripts/ci/classify_changes.sh | tests/integration/run.py | pyproject.toml | uv.lock)
has_redis_compat=true ;;
esac
case "$file" in
*.md | *.mdx) : ;;
pyproject.toml | */pyproject.toml | uv.lock | uv.toml | .python-version | rust-toolchain.toml | litellm-rust/* | litellm/__init__.py | litellm/proxy/proxy_server.py | litellm/*mcp* | tests/*mcp* | litellm/integrations/arize/* | tests/base_sdk_tests/* | scripts/check_mcp_sdk_install.py | .github/workflows/test-mcp-dependency-resolution.yml | .github/actions/detect-changes/* | .github/actions/setup-uv-with-retries/* | .github/actions/cache-cargo-build/* | .github/scripts/detect_changes.sh | .github/scripts/uv_sync_with_retries.sh | .circleci/scripts/classify_changes.sh | tests/unit/test_circleci_path_filter.py | tests/unit/test_detect_changes.py)
pyproject.toml | */pyproject.toml | uv.lock | uv.toml | .python-version | rust-toolchain.toml | litellm-rust/* | litellm/__init__.py | litellm/proxy/proxy_server.py | litellm/*mcp* | tests/*mcp* | litellm/integrations/arize/* | tests/base_sdk_tests/* | scripts/check_mcp_sdk_install.py | .github/workflows/test-mcp-dependency-resolution.yml | .github/actions/detect-changes/* | .github/actions/setup-uv-with-retries/* | .github/actions/cache-cargo-build/* | .github/scripts/detect_changes.sh | .github/scripts/uv_sync_with_retries.sh | .github/scripts/ci/classify_changes.sh | tests/unit/test_ci_path_filter.py | tests/unit/test_detect_changes.py)
has_mcp_dependencies=true ;;
esac
case "$file" in
litellm-rust/* | litellm/rust_bridge/* | rust-toolchain.toml | pyproject.toml | uv.lock | tests/windows_tests/* | .circleci/*)
litellm-rust/* | litellm/rust_bridge/* | rust-toolchain.toml | pyproject.toml | uv.lock | tests/windows_tests/* | .github/scripts/ci/* | .github/actions/*)
has_windows_release=true ;;
esac
case "$file" in
ui/* | tests/e2e/ui/*) has_client=true ;;
docs/* | *.md | *.mdx) : ;;
.github/* | .circleci/*) has_ci=true; has_backend=true ;;
.github/*) has_ci=true; has_backend=true ;;
*) has_backend=true ;;
esac
case "$file" in

View file

@ -6,10 +6,12 @@ here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
run_full() {
echo "path-filter[$category]: running job ($1)"
echo "decision=run"
[ -z "${GITHUB_OUTPUT:-}" ] || echo "decision=run" >> "$GITHUB_OUTPUT"
exit 0
}
[ -n "${CIRCLE_PULL_REQUEST:-}" ] || run_full "not a pull request"
[ "${GITHUB_EVENT_NAME:-}" = pull_request ] || run_full "not a pull request"
candidate_bases="${PATH_FILTER_BASE_BRANCH:-main}"
merge_base=""
@ -36,5 +38,6 @@ if [ "$decision" = run ]; then
run_full "$category-relevant changes detected"
fi
echo "path-filter[$category]: only unrelated changes detected; halting job as successful"
circleci-agent step halt
echo "path-filter[$category]: only unrelated changes detected"
echo "decision=skip"
[ -z "${GITHUB_OUTPUT:-}" ] || echo "decision=skip" >> "$GITHUB_OUTPUT"

View file

@ -30,7 +30,7 @@ egress_cgroup=litellm-integration
cleanup() {
original_status=$?
trap - EXIT INT TERM
sudo .venv/bin/python .circleci/scripts/stop_integration_processes.py \
sudo .venv/bin/python .github/scripts/ci/stop_integration_processes.py \
"$integration_identity" "$(id -u)" "$proxy_pid" "$peer_pid" "$upstream_pid" \
> "$results/process-cleanup.txt" 2>&1 || original_status=1
for owned_pid in "$peer_pid" "$proxy_pid" "$upstream_pid"; do
@ -84,7 +84,7 @@ if [ "$suite" = browser ]; then
export LITELLM_UI_PATH="$PWD/ui/litellm-dashboard/out"
test -f "$LITELLM_UI_PATH/index.html"
fi
export INTEGRATION_SEED="$(.venv/bin/python -c 'import hashlib,os; print(int(hashlib.sha256(((os.environ.get("CIRCLE_SHA1") or os.environ.get("GITHUB_SHA") or "local") + (os.environ.get("CIRCLE_WORKFLOW_ID") or os.environ.get("GITHUB_RUN_ID") or "local")).encode()).hexdigest()[:8],16))')"
export INTEGRATION_SEED="$(.venv/bin/python -c 'import hashlib,os; print(int(hashlib.sha256(((os.environ.get("GITHUB_SHA") or "local") + (os.environ.get("GITHUB_RUN_ID") or "local")).encode()).hexdigest()[:8],16))')"
export INTEGRATION_ORDER_SEED="$INTEGRATION_SEED"
uv run --no-sync prisma generate --schema litellm/proxy/schema.prisma > "$results/prisma-generate.log" 2>&1
@ -93,7 +93,7 @@ export INTEGRATION_PROXY_DATABASE_URL=""
export INTEGRATION_PROXY_READ_REPLICA_URL=""
export INTEGRATION_ROUTING=""
if [ "$mode" = replica ] || [ "$mode" = parity ]; then
.venv/bin/python .circleci/scripts/prepare_replica_roles.py > "$results/prepare-replica-roles.log" 2>&1
.venv/bin/python .github/scripts/ci/prepare_replica_roles.py > "$results/prepare-replica-roles.log" 2>&1
export INTEGRATION_PROXY_DATABASE_URL="postgresql://litellm_writer:litellm-writer@127.0.0.1:5432/circle_test"
export INTEGRATION_PROXY_READ_REPLICA_URL="postgresql://litellm_reader:litellm-reader@127.0.0.1:5432/circle_test"
fi
@ -202,7 +202,7 @@ start_proxy() {
if [ "$isolated_workers" = false ]; then
start_proxy 4000 proxy.log
proxy_pid="$launched_pid"
.venv/bin/python .circleci/scripts/wait_integration_services.py
.venv/bin/python .github/scripts/ci/wait_integration_services.py
curl --noproxy '*' -sSf -X POST "$INTEGRATION_PROXY_URL/config/update" \
-H "Authorization: Bearer $LITELLM_MASTER_KEY" -H 'Content-Type: application/json' \
-d '{"router_settings": {"num_retries": 0}}' > "$results/seed-router-settings.json"
@ -210,7 +210,7 @@ if [ "$isolated_workers" = false ]; then
export INTEGRATION_PEER_URL=http://127.0.0.1:4001
start_proxy 4001 peer.log
peer_pid="$launched_pid"
.venv/bin/python .circleci/scripts/wait_integration_services.py
.venv/bin/python .github/scripts/ci/wait_integration_services.py
fi
fi
@ -233,14 +233,13 @@ if [ "$suite" = browser ]; then
E2E_UI_BASE_URL="$E2E_UI_BASE_URL" E2E_UI_ARTIFACT_DIR="$E2E_UI_ARTIFACT_DIR" \
LITELLM_MASTER_KEY="$LITELLM_MASTER_KEY" CI=true \
node tests/e2e/ui/node_modules/@playwright/test/cli.js test --config tests/e2e/ui/integration.config.ts
.venv/bin/python .circleci/scripts/verify_integration_browser.py "$results/browser-results.json"
.venv/bin/python .github/scripts/ci/verify_integration_browser.py "$results/browser-results.json"
exit 0
fi
shard_arguments=()
if [ "${CIRCLE_NODE_TOTAL:-1}" -gt 1 ]; then
.venv/bin/python tests/integration/run.py "$suite" --list \
| circleci tests split --split-by=timings --timings-type=filename > "$results/node-files.txt"
if [ -n "${INTEGRATION_SHARD_FILES:-}" ]; then
cp "$INTEGRATION_SHARD_FILES" "$results/node-files.txt"
shard_arguments=(--shard-files "$results/node-files.txt")
fi
@ -265,7 +264,7 @@ env -i PATH="$PATH" HOME="$HOME" PYTHONPATH="$PYTHONPATH" \
"${license_env[@]}" \
INTEGRATION_SEED="$INTEGRATION_SEED" \
INTEGRATION_ORDER_SEED="$INTEGRATION_ORDER_SEED" \
INTEGRATION_CASE_SHARDS="${CIRCLE_NODE_TOTAL:-1}" INTEGRATION_CASE_SHARD_INDEX="${CIRCLE_NODE_INDEX:-0}" \
INTEGRATION_CASE_SHARDS="${INTEGRATION_CASE_SHARDS:-1}" INTEGRATION_CASE_SHARD_INDEX="${INTEGRATION_CASE_SHARD_INDEX:-0}" \
LITELLM_LOCAL_MODEL_COST_MAP=True AWS_EC2_METADATA_DISABLED=true DO_NOT_TRACK=1 \
INTEGRATION_PROXY_DATABASE_URL="$INTEGRATION_PROXY_DATABASE_URL" \
INTEGRATION_PROXY_READ_REPLICA_URL="$INTEGRATION_PROXY_READ_REPLICA_URL" \

View file

@ -31,7 +31,7 @@ def main() -> None:
result: Final = json.loads(Path(sys.argv[1]).read_text())
assert not result.get("errors"), result.get("errors")
expected: Final = json.loads(
(Path(__file__).resolve().parents[2] / "tests/e2e/ui/tests/integrationCritical/expected.json").read_text()
(Path(__file__).resolve().parents[3] / "tests/e2e/ui/tests/integrationCritical/expected.json").read_text()
)
assert expected and result["stats"]["expected"] == len(expected)
assert all(result["stats"][name] == 0 for name in ("unexpected", "flaky", "skipped"))
@ -41,7 +41,7 @@ def main() -> None:
suites: Final = TypeAdapter(list[BrowserSuite]).validate_python(result["suites"], strict=True)
specs: Final = tuple(spec for suite in suites for spec in cases(suite))
repository: Final = Path(__file__).resolve().parents[2]
repository: Final = Path(__file__).resolve().parents[3]
report_root: Final = Path(result["config"]["rootDir"])
assert report_root.is_absolute(), "Playwright rootDir must be explicit"
observed: Final = tuple(

View file

@ -16,7 +16,7 @@ run_full() {
}
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
classify="${here}/../../.circleci/scripts/classify_changes.sh"
classify="${here}/ci/classify_changes.sh"
[ -n "${PR_NUMBER:-}" ] || run_full "not a pull_request event"
[ -n "${REPO:-}" ] || run_full "no repository in the environment"

View file

@ -55,7 +55,6 @@ jobs:
services:
postgres:
image: postgres:16@sha256:e17e86066e5ef83e0952a9347f5c792b7ece00972e2aa787a6986f471b3dd3d5
command: -c max_connections=1000 -c shared_preload_libraries=pg_stat_statements -c pg_stat_statements.track=all -c pg_stat_statements.max=20000
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
@ -81,6 +80,17 @@ jobs:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
- name: Configure PostgreSQL for integration workers
env:
POSTGRES_CONTAINER: ${{ job.services.postgres.id }}
run: |
docker exec "$POSTGRES_CONTAINER" sh -c 'printf "%s\n" "max_connections = 1000" "shared_preload_libraries = pg_stat_statements" "pg_stat_statements.track = all" "pg_stat_statements.max = 20000" >> "$PGDATA/postgresql.conf"'
docker restart "$POSTGRES_CONTAINER"
for attempt in $(seq 1 30); do
if docker exec "$POSTGRES_CONTAINER" pg_isready -U postgres; then exit 0; fi
sleep 1
done
exit 1
- name: Set up Python
timeout-minutes: 3
@ -155,8 +165,8 @@ jobs:
LITELLM_LICENSE: ${{ secrets.LITELLM_LICENSE }}
run: |
case "$SUITE" in
*-replica) bash .circleci/scripts/run_integration.sh "${SUITE%-replica}" replica ;;
*) bash .circleci/scripts/run_integration.sh "$SUITE" ;;
*-replica) bash .github/scripts/ci/run_integration.sh "${SUITE%-replica}" replica ;;
*) bash .github/scripts/ci/run_integration.sh "$SUITE" ;;
esac
- name: Verify every case ran exactly once

View file

@ -28,7 +28,7 @@ on:
- ".github/actions/cache-cargo-build/**"
- ".github/scripts/detect_changes.sh"
- ".github/scripts/uv_sync_with_retries.sh"
- ".circleci/scripts/classify_changes.sh"
- ".github/scripts/ci/classify_changes.sh"
permissions:
contents: read

View file

@ -20,7 +20,7 @@ import json
import os
import subprocess
import sys
from collections.abc import Iterator, Mapping, Sequence
from collections.abc import Callable, Iterator, Mapping, Sequence
from dataclasses import dataclass
from types import MappingProxyType
from typing import Final
@ -28,8 +28,13 @@ from typing import Final
COST_MAP_PATH: Final = "model_prices_and_context_window.json"
BACKUP_PATH: Final = "litellm/model_prices_and_context_window_backup.json"
PINS_PATH: Final = "ci_cd/cost_map_pins.json"
PINS_PATHS: Final = (
"litellm/scripts/ci_cd/cost_map_pins.json",
"scripts/ci_cd/cost_map_pins.json",
"ci_cd/cost_map_pins.json",
)
GUARDED_PATHS: Final = (COST_MAP_PATH, BACKUP_PATH)
CHECKED_PATHS: Final = (*GUARDED_PATHS, PINS_PATH)
CHECKED_PATHS: Final = (*GUARDED_PATHS, *PINS_PATHS)
BOT_BRANCH_PREFIX: Final = "litellm_cost_map_sync_"
PRICE_SYNC_BRANCH_PREFIX: Final = "litellm-providers/price-sync"
TARGET_ENV: Final = "COST_MAP_GUARD_TARGET"
@ -193,7 +198,7 @@ def _bot_failures(base: Snapshot, head_map: CostMap, changed_files: Sequence[str
def pin_author_failures(head_ref: str, author_type: str, changed_files: Sequence[str]) -> tuple[str, ...]:
if PINS_PATH not in changed_files:
if not any(path in changed_files for path in PINS_PATHS):
return ()
if head_ref.startswith(PRICE_SYNC_BRANCH_PREFIX):
return (f"price sync PRs may not change {PINS_PATH}; a pin moves only in a human PR with new evidence",)
@ -251,8 +256,11 @@ def _git(*args: str) -> str | None:
return result.stdout if result.returncode == 0 else None
def snapshot(revision: str) -> Snapshot:
return Snapshot(*(_git("show", f"{revision}:{path}") or "" for path in CHECKED_PATHS))
def snapshot(revision: str, *, read_git: Callable[[str, str], str | None] = _git) -> Snapshot:
return Snapshot(
*(read_git("show", f"{revision}:{path}") or "" for path in GUARDED_PATHS),
next((text for path in PINS_PATHS if (text := read_git("show", f"{revision}:{path}")) is not None), ""),
)
def merged_tree(merge_base: str, target: str, head: str) -> str | MergeFailed | None:

View file

@ -6,7 +6,7 @@ codecov:
ignore:
- "litellm-rust/**"
# Uploads are flagged per workflow tier (GHA) or "circleci". carryforward makes
# Uploads are flagged per workflow tier. carryforward makes
# a re-upload of a flag replace its prior session instead of accumulating a
# conflicting one, and lets a commit reuse a flag from its parent when that flag
# was not re-uploaded. Required because the same commit can receive the

View file

@ -1,4 +1,4 @@
# Throwaway PostgreSQL for the CircleCI helm_chart_testing job. Applied with
# Throwaway PostgreSQL for the local chart smoke test. Applied with
# kubectl before `helm install`; the chart itself ships no database.
apiVersion: v1
kind: Secret

View file

@ -1,4 +1,4 @@
# Values for the CircleCI helm_chart_testing job: a monolith install on a
# Values for the local chart smoke test: a monolith install on a
# kind cluster from the image built by the pipeline (image.repository / tag /
# pullPolicy are passed with --set), backed by the throwaway PostgreSQL in
# ci/postgres.yaml so the migrations Job and the proxy have a database.

View file

@ -102,7 +102,7 @@ def reset_vcr_diag_dir() -> None:
pass
# CircleCI truncates a step's retrievable output to the last ~400 KB. The
# CI truncates a step's retrievable output to the last ~400 KB. The
# diagnostic log is emitted right *before* the final pytest summary line but
# *after* the VCR CLASSIFICATION SUMMARY, so an unbounded dump (the body/key
# matchers log one block per *episode comparison*, even on an eventual HIT)

View file

@ -30,7 +30,7 @@ deferred. It needs credentials
Each subdirectory under `tests/e2e/` is one suite, scoped to an endpoint family or behavior area. If you add a new folder, you must add a line here describing what kind of tests belong in it, so the layout stays self-describing. `gateway/` is the exception: it holds proxy configuration only and never tests
- `migrations/` - isolated Docker startup, concurrent migration, crash recovery, and legacy database compatibility. The CircleCI migration workflow enables `LITELLM_MIGRATION_TESTS=1`. These tests own their proxy containers and databases, so they do not use the shared proxy preflight or shared database cleanup
- `migrations/` - isolated Docker startup, concurrent migration, crash recovery, and legacy database compatibility. Set `LITELLM_MIGRATION_TESTS=1` to run this suite manually. No CI workflow invokes it. These tests own their proxy containers and databases, so they do not use the shared proxy preflight or shared database cleanup
- `llm_translation/` - Azure realtime translation is explicitly skipped pending investigation of intermittent missing transcripts and remains unqualified. OpenAI translation and other Azure realtime cases remain enabled. LLM endpoint and provider-translation behavior: passthrough, custom pricing, OCR, and the non-chat inference endpoints (`/v1/responses`, `/v1/messages`, `/embeddings`, `/v1/rerank`, `/v1/audio/speech`, `/v1/images/generations`), each against a deployment the test creates via `/model/new` and deletes on teardown
- `access_control/` - the gateway's authorization and error-shape contract: per-key model allow-lists, route-group permissions (`allowed_routes`), and unknown-model validation

View file

@ -130,8 +130,7 @@ async def systemone(request: Request):
if __name__ == "__main__":
# The port is overridable so two checkouts can run the harness at the same
# time; the default keeps every existing caller (run_e2e.sh, the CircleCI
# job, the e2e chart's sidecar) working untouched.
# time; the default keeps run_e2e.sh and the e2e chart's sidecar working untouched.
#
# The HOST is deliberately NOT configurable. Binding loopback is what makes
# this reachable at 127.0.0.1:8090 from inside the proxy's own pod, which is

View file

@ -2,9 +2,6 @@ import { defineConfig, devices } from "@playwright/test";
import * as path from "path";
import { ARTIFACT_DIR, UI_BASE_URL } from "./constants";
if (process.env.GITHUB_ACTIONS === "true")
throw new Error("Integration contracts are owned by CircleCI");
export default defineConfig({
testDir: "./tests/integrationCritical",
testMatch: "*.spec.ts",

View file

@ -36,7 +36,7 @@ PROXY_LOG=""
# Ports, overridable so two checkouts can run this harness at the same time --
# otherwise a second run aborts on "port 4000 is in use" and the only way out is
# to stop someone else's stack. Defaults are the historical values, so an unset
# environment behaves exactly as before (CI, the CircleCI job and the docs all
# environment behaves exactly as before (CI and the docs
# assume 4000/5432/8090).
PROXY_PORT="${PROXY_PORT:-4000}"
POSTGRES_PORT="${POSTGRES_PORT:-5432}"

View file

@ -3,9 +3,9 @@ import { Role, users } from "../../fixtures/users";
import { logInThroughLoginPage } from "../../helpers/userOnboarding";
/**
* Runs as part of the standard e2e suite: both `run_e2e.sh` and the CircleCI
* `e2e_ui_testing` job boot the proxy with PROXY_LOGOUT_URL=https://www.example.com
* and export the same value to this Playwright process. The spec reads it to
* Runs as part of the standard e2e suite. `run_e2e.sh` boots the proxy with
* PROXY_LOGOUT_URL=https://www.example.com and exports the same value to
* this Playwright process. The spec reads it to
* know where the browser is expected to land.
*
* The skip guard below is a safety net for environments that launch the proxy

View file

@ -4,11 +4,11 @@ import { ADMIN_STORAGE_PATH } from "../../constants";
/**
* Sanity check that LITELLM_LICENSE is being forwarded to the proxy when set
* in the environment (e.g. CircleCI's `e2e_ui_testing` job). The login JWT's
* in the environment (e.g. `run_e2e.sh`). The login JWT's
* `premium_user` claim is the same value the dashboard reads to enable
* premium-gated UI surfaces (Team-BYOK switch, etc.), so asserting it here
* catches any future regression where the env var stops being plumbed
* through `run_e2e.sh` / `.circleci/config.yml`.
* through `run_e2e.sh`.
*
* Skips locally when no license is configured.
*/

View file

@ -26,20 +26,20 @@ directory that a `GROUPS` entry in `run.py` selects. There is no manifest and no
tests. A product bug the test exposes is `pytest.skip("BUG: <symptom>")` at the top of the body, not a
fix in the test and not a deletion. Needs no network, proxy, DB or Redis: `tests/unit`
CircleCI's integration groups run every file here except the OCI and sandbox tests, which no CI runs, and
GitHub Actions integration groups run every file here except the OCI and sandbox tests, which no CI runs, and
the files in `GITHUB_FILES` in `run.py`. `test-integration.yml` runs the same groups through the same
`run_integration.sh` on GitHub Actions, on main every three hours from 8am to 8pm Pacific Monday to Saturday,
and on a pull request when someone adds the `run-ci` label. Neither runs every pull request, so a new file here
and on a pull request when someone adds the `run-ci` label. These jobs do not run every pull request, so a new file here
first runs on main after its PR merges, or earlier if the PR gets `run-ci`. Wanting a file to run before merge
is no reason to change CI, and a new
dedicated workflow for it is the same mistake as a shared shard. Write the file, leave `GITHUB_FILES` and
every workflow alone, and tell the user it first runs on main. `GITHUB_FILES` is only for a file that
needs what CircleCI's groups cannot give it, like the images `image-scan.yml` builds or the plain pytest
run CircleCI's `roi-database` job gives `test_roi_observed.py`. Add a file there only in that case,
needs what GitHub Actions groups cannot give it, like the images `image-scan.yml` builds. `test_roi_observed.py` previously needed a dedicated plain pytest
job and now has no CI owner. It is listed explicitly in the coverage allowlist. Add a file to `GITHUB_FILES` only when needed,
together with its dedicated job, and list a new dedicated GitHub workflow with the directory it owns in
`DEDICATED_INTEGRATION_WORKFLOWS` in `.github/scripts/assert_ci_coverage.py`. The `ci-coverage` check
fails a file here that a GitHub workflow runs unless it is in `GITHUB_FILES` and that workflow owns its
directory (#46780)
fails a file here that another workflow selects unless that workflow owns its path in
`DEDICATED_INTEGRATION_WORKFLOWS` (#46780).
## Fixtures that boot a proxy
@ -50,4 +50,4 @@ on one xdist worker, so the proxy they share boots there once and stops when tha
file. A test that requests one more of those fixtures than its neighbors gets its own group and can boot
the proxy again on another worker, so keep that set the same across the tests that share a proxy. An
unpinned session fixture boots a copy on every worker that runs one of its tests and keeps them all until
the node ends, and those copies ran CircleCI integration-extensions nodes out of memory
the node ends, and those copies ran GitHub Actions integration-extensions nodes out of memory

View file

@ -1,24 +1,24 @@
# Integration contracts
These tests exercise a running gateway, PostgreSQL and Redis with an owned local upstream. CircleCI owns this suite, and `.github/workflows/test-integration.yml` runs the same groups through `run_integration.sh` on GitHub Actions for `run-ci` pull requests and scheduled main runs. Tests are grouped by behavior, with no automatic test retries or fallback to paid provider calls
These tests exercise a running gateway, PostgreSQL and Redis with an owned local upstream. `.github/workflows/test-integration.yml` runs the groups through `.github/scripts/ci/run_integration.sh` for `run-ci` pull requests and scheduled main runs. Tests are grouped by behavior, with no automatic test retries or fallback to paid provider calls
The ROI database contracts in `database/test_roi_observed.py` run as plain pytest outside `run_integration.sh` in CircleCI's `roi-database` Postgres job. The job uploads coverage with the `roi-postgres` flag. These contracts own temporary databases and script only the external provider transport
The ROI database contracts in `database/test_roi_observed.py` run as plain pytest outside `run_integration.sh`. They own temporary databases and script only the external provider transport. No remaining CI job invokes them. They are listed explicitly in `.github/ci-coverage-allowlist.yml`.
The Redis cluster IAM contracts in `sdk/test_redis_cluster_iam_auth.py` start their own `redis-server` and mock the cloud credential clients, so CircleCI's `sdk` group installs `redis-server` before it runs them
The Redis cluster IAM contracts in `sdk/test_redis_cluster_iam_auth.py` start their own `redis-server` and mock the cloud credential clients, so the GitHub Actions `sdk` group installs `redis-server` before it runs them
The `cost` group is driven by the `CostTrackingTestCase` literals under `cost_calculation/cases/<provider>.py` (gathered by `cost_calculation/catalog.py`), with the scripted cost map in `cost_calculation/cost_map.py`. Each case has a name, contract ID, cost-map model, optional deployment overrides, request body, tagged response and exact or recount expectations. Request bodies use `$MODEL` for the registered proxy model, while responses use `$REQUEST_ID` for the per-run scenario ID. To add a case, add a cost-map entry when the model is new, add the request body and exact provider response data, and add hand-computed expected values. The upstream serves each stored response for any path under `/<scenario_id>`, while the test-owned cost map is served over loopback through `LITELLM_MODEL_COST_MAP_URL`
Use `tests/integration/run.py management`, `accounting`, `database`, `providers`, `extensions`, `mcp`, `sdk` or `cost` to run a selected group. The group to directory mapping is the `GROUPS` literal at the top of `run.py`; a new directory needs a `GROUPS` entry and an `OWNED_DIRECTORIES` entry in `_support/manifest.py`. Set `INTEGRATION_WORKERS` above 1 to run a group under pytest-xdist. Every group run by more than one worker gives each worker a private database, Redis, scripted upstream process, fake provider and proxy, with a peer and read-only database role where required. There is no opt-in list and no shared proxy, upstream, database or Redis in that mode: `run_integration.sh` does not start them and does not hand their addresses to the run, so a test can only reach its own worker's services. The pytest controller owns these services and closes them after worker failures. Their cases use pytest-xdist load groups inside the files selected by CircleCI timing splits. Module and class fixtures are grouped automatically, so authors do not need scheduling markers. Function fixtures remain independently schedulable. Tests that own an independent mutable sink for every case should use function fixtures, so a shared module fixture does not serialize them or share pending observations. Nested harness probes discard the parent run's shard, worker and result settings, then supply their own selections. MCP keeps its explicit load groups. Automatic group suffixes are removed from JUnit and execution records to preserve case identities. Owned proxy helpers wait for a healthy response from every configured, live worker before yielding. Serial runs keep the supplied deployment, including routing parity runs. Set `INTEGRATION_PROXY_URL`, `INTEGRATION_UPSTREAM_URL`, `INTEGRATION_MASTER_KEY` and `DATABASE_URL` to an isolated test deployment. When that deployment runs more than one proxy worker, set `INTEGRATION_PROXY_WORKERS` to the count so a test that writes a model and then calls it waits out the config reload interval, the only cross-worker convergence bound the wire exposes. The runner selects the new domain directories explicitly; the legacy OCI and sandbox selections remain separate
Use `tests/integration/run.py management`, `accounting`, `database`, `providers`, `extensions`, `mcp`, `sdk` or `cost` to run a selected group. The group to directory mapping is the `GROUPS` literal at the top of `run.py`; a new directory needs a `GROUPS` entry and an `OWNED_DIRECTORIES` entry in `_support/manifest.py`. Set `INTEGRATION_WORKERS` above 1 to run a group under pytest-xdist. Every group run by more than one worker gives each worker a private database, Redis, scripted upstream process, fake provider and proxy, with a peer and read-only database role where required. There is no opt-in list and no shared proxy, upstream, database or Redis in that mode: `run_integration.sh` does not start them and does not hand their addresses to the run, so a test can only reach its own worker's services. The pytest controller owns these services and closes them after worker failures. Their cases use pytest-xdist load groups within each group's selected files. Module and class fixtures are grouped automatically, so authors do not need scheduling markers. Function fixtures remain independently schedulable. Tests that own an independent mutable sink for every case should use function fixtures, so a shared module fixture does not serialize them or share pending observations. Nested harness probes discard the parent run's shard, worker and result settings, then supply their own selections. MCP keeps its explicit load groups. Automatic group suffixes are removed from JUnit and execution records to preserve case identities. Owned proxy helpers wait for a healthy response from every configured, live worker before yielding. Serial runs keep the supplied deployment, including routing parity runs. Set `INTEGRATION_PROXY_URL`, `INTEGRATION_UPSTREAM_URL`, `INTEGRATION_MASTER_KEY` and `DATABASE_URL` to an isolated test deployment. When that deployment runs more than one proxy worker, set `INTEGRATION_PROXY_WORKERS` to the count so a test that writes a model and then calls it waits out the config reload interval, the only cross-worker convergence bound the wire exposes. The runner selects the new domain directories explicitly; the legacy OCI and sandbox selections remain separate
Management, MCP and accounting also require `INTEGRATION_PEER_URL`, `REDIS_HOST` and `REDIS_PORT`. Under several workers each worker gets a second directly addressed proxy sharing only that worker's stores, and a single-worker run uses the two proxies `run_integration.sh` starts. The test-only CLI wrapper supplies enterprise route entitlement, following the existing behavior suite's convention. It does not qualify license validation; run it with one worker and no reload
The generated lifecycle models use 20 examples, eight steps, generation and shrinking, with isolated resources per example. HTTP operation caps include generation and shrinking and exempt cleanup. Local qualification defaults to seed 4106601 and canonical order; CircleCI derives exploration and ordering seeds from the checked-out revision and workflow ID. The ordering seed shuffles files, scoped fixture configurations and cases within each configuration. Ordering keeps shared module and class configurations together where their dependencies permit it. Dependent fixtures with their own module parameters can still rebuild an outer configuration, with every case retained
The generated lifecycle models use 20 examples, eight steps, generation and shrinking, with isolated resources per example. HTTP operation caps include generation and shrinking and exempt cleanup. Local qualification defaults to seed 4106601 and canonical order; GitHub Actions derives exploration and ordering seeds from the checked-out revision and workflow ID. The ordering seed shuffles files, scoped fixture configurations and cases within each configuration. Ordering keeps shared module and class configurations together where their dependencies permit it. Dependent fixtures with their own module parameters can still rebuild an outer configuration, with every case retained
CircleCI uses its built-in `circleci tests split --split-by=timings --timings-type=filename` to assign test files using the uploaded JUnit timings. Each file stays on one node, preserving its module and parametrized fixtures. Jobs default to one node. Parallelism should use the smallest node count that keeps the complete job, including setup and cleanup, within twenty minutes. A group that already finishes within that target on one node stays on one node. Management, accounting, providers and management replica currently use four nodes. Extensions uses eight nodes; database and MCP use two, and fast suites use one. No job is configured above eight nodes. A single slow file can still determine a suite's runtime; split such a file along independent fixture boundaries rather than adding more nodes
GitHub Actions runs one job per suite and uses pytest-xdist workers inside that job. `INTEGRATION_WORKERS` controls the worker count, with defaults in `test-integration.yml`. Each worker owns its services. A slow file can still determine a suite's runtime, so split it along independent fixture boundaries rather than adding more workers
Every node collects the same candidate files before applying CircleCI's assigned file list. Its execution record retains the candidate inventory and the selected, passed and skipped cases. An empty assigned file list produces an empty shard, not a full-suite fallback. Every parallel group has a dependent census job that rejects an empty candidate inventory, missing shards, omitted cases and duplicate execution. Standard and replica runs keep separate workspace records
Every group records its candidate inventory and the selected, passed and skipped cases. A census step rejects an empty candidate inventory, omitted cases and duplicate execution. Standard and replica runs keep separate records
Future change-based selection belongs before timing splitting. Pass the selected group files or node IDs to `run.py <group> --list` to list their files, then give those filenames to CircleCI. Run the same candidate selection on every node with `--shard-files <CircleCI-output-path> --shards <count> --shard-index <index>`. The census compares execution with the candidate inventory, while full-suite runs remain the default. CircleCI determines scheduling from timing history; timing data never selects or omits candidates. Use `--seed` and `--order-seed` to reproduce test generation and order. The installed Hypothesis version, settings, seeds and collected order are recorded beside the execution manifest
Future change-based selection belongs before worker scheduling. Pass the selected group files or node IDs to `run.py <group> --list` to list their files. An external shard assignment can use `INTEGRATION_SHARD_FILES` with `INTEGRATION_CASE_SHARDS` and `INTEGRATION_CASE_SHARD_INDEX`. An empty assigned file list produces an empty shard, not a full-suite fallback. The current GitHub Actions jobs use one shard with index zero and use pytest-xdist within it. Use `--seed` and `--order-seed` to reproduce test generation and order. The installed Hypothesis version, settings, seeds and collected order are recorded beside the execution manifest
A future selector must explicitly skip a group when no candidates apply; omitting positional candidates means the full group. Keep full runs on the default branch and scheduled workflows, and select the full affected groups when shared fixtures, CI configuration or an unknown dependency changes
@ -30,9 +30,9 @@ Owned proxy workers configure a 32-thread executor before application startup, p
Every owned listener's default port comes from `allocate_port()` in `_support/ports.py`. That covers owned proxies, scripted upstreams, fake providers, OTLP sinks, database relays and Redis, including peers such as `wire_server` that bind and keep their own sockets. Keeping those listeners out of the ephemeral range prevents them from taking a port Prisma selected with a bind-and-close probe before its query engine starts. Each pytest process claims a block of 200 ports between 10000 and 32767 by holding an exclusive lock file per block in the system temp directory, so two runs on one machine, such as parallel worktrees, never share a block, and the OS drops the lock when the process exits. The range sits above the fixed service ports and below the Linux and macOS ephemeral ranges, so a port-0 bind or an outbound connection never lands in a block. Ports inside a block go out in order and are never handed out twice in a run, a port that something outside the harness already holds is skipped, and a process that uses up its block claims the next free one. A test that restarts a server on the same port allocates that port up front instead of reading it back from the first server. TQ012 in `scripts/check_test_quality.py` rejects reading a port back from a port-0 bind anywhere under `tests/integration`
The CircleCI workflow starts its own database and Redis, restricts test-phase egress to its owned services and writes JUnit plus an executed-node manifest. Missing setup, failed cleanup or a selected test with neither a passed call nor a skip fail qualification. Skipped nodes are listed under `skipped` in `execution.json`, so the skip reasons double as the open bug list. `GITHUB_FILES` in `run.py` lists the files excluded from the `run_integration.sh` selection
The GitHub Actions workflow starts its own database and Redis, restricts test-phase egress to its owned services and writes JUnit plus an executed-node manifest. Missing setup, failed cleanup or a selected test with neither a passed call nor a skip fail qualification. Skipped nodes are listed under `skipped` in `execution.json`, so the skip reasons double as the open bug list. `GITHUB_FILES` in `run.py` lists the files excluded from the `run_integration.sh` selection
There is no hand-maintained list of test cases. A positional argument is a file of the group or a pytest node id inside one (`path::test[param]`), so one cell of a parametrized file can run alone. The runner fails only when pytest fails, when collection errors, or when a selected file collects zero tests. Older tests still carry `@pytest.mark.covers(...)` decorators; the marker stays registered so they collect, but the IDs are not checked against anything and new tests should not use it. The GitHub Actions coverage census reads the `GROUPS` literal in `run.py` and treats every `tests/integration/<directory>/test_*.py` file in a scheduled group as owned by CircleCI
There is no hand-maintained list of test cases. A positional argument is a file of the group or a pytest node id inside one (`path::test[param]`), so one cell of a parametrized file can run alone. The runner fails only when pytest fails, when collection errors, or when a selected file collects zero tests. Older tests still carry `@pytest.mark.covers(...)` decorators; the marker stays registered so they collect, but the IDs are not checked against anything and new tests should not use it. The GitHub Actions coverage census reads the `GROUPS` literal in `run.py` and treats every `tests/integration/<directory>/test_*.py` file in a scheduled group as owned by GitHub Actions
Provider sentinels currently use the controlled server, not live recordings. The provider shard also runs the existing strict replay controls for changed requests, exhausted interactions, leftover interactions and no provider connection. Future recorded scenarios must use that replay-only implementation; missing recordings cannot fall back to a real provider. `GET /__observations` returns every request the upstream recorded since the last `DELETE /__observations`, and reading it never clears it. An autouse fixture resets it before each test, so a test that needs a clean slate between phases sends the DELETE itself. A check that a request never reached the upstream asserts on the same read that shows a request sent after it did reach the upstream, so the absence cannot pass on an empty or reset log
@ -46,20 +46,20 @@ Accounting cases compare persisted input and output cost components against lite
Provider contracts exercise actual TCP requests with synthetic credentials and local protocol peers. The S3 verifier uses independently implemented equations, a published known-answer vector, a fixed signing clock and deliberately invalid signed requests. Bedrock cases clear ambient AWS credential sources and check the literal model path, loaded role references, STS requests and bearer-only behavior
CircleCI runs providers on four isolated nodes using its file timing splitter. Each node owns its services. The dependent providers coverage job verifies all four execution records against the candidate case inventory and rejects missing, duplicate, failed or incomplete execution. The node's assigned filenames are saved in `node-files.txt` beside its execution record
GitHub Actions runs the providers group with 32 pytest workers, each with its own services. The census step verifies the execution record against the candidate inventory and rejects missing, duplicate, failed or incomplete execution
Streaming checks send real HTTP transfer chunks, including one-byte partitions, fragmented tools, incomplete transfers and a cancellation barrier. They assert meaningful text, tool arguments, final usage and persisted cost. The Redis recovery case owns a separate database and Redis process, uses the supported one-second circuit-breaker recovery setting, waits for the real subscriber and verifies response data in Redis after restart. CircleCI reuses its existing Redis image for that extra process; it never pulls an image during tests
Streaming checks send real HTTP transfer chunks, including one-byte partitions, fragmented tools, incomplete transfers and a cancellation barrier. They assert meaningful text, tool arguments, final usage and persisted cost. The Redis recovery case owns a separate database and Redis process, uses the supported one-second circuit-breaker recovery setting, waits for the real subscriber and verifies response data in Redis after restart. GitHub Actions reuses its existing Redis image for that extra process; it never pulls an image during tests
The `messages_endpoint/` directory holds `/v1/messages` endpoint contracts: native-provider backends under `providers/` (`anthropic`, `bedrock`, `gemini`) and the translation bridges (`responses_bridge`, `chat_bridge`) at the top level. It runs in the providers shard; `run.py` selects test files recursively under each scheduled directory
The sdk shard exercises the SDK's own HTTP clients against local protocol peers with no gateway in the path, so a case here fails only when the client library or its wire behavior changes. The HTTP/2 case runs a hypercorn TLS peer offering h2 and http/1.1 over ALPN, drives the sync and async httpx handlers at it with `LITELLM_HTTP2` off and on, and asserts the version both the client and the peer observed on the wire. Put a test here only when it needs no proxy or database. CircleCI starts a local Redis for this shard like the others, so SDK-side caching cases that need a real Redis server belong here too; a case that reaches the gateway belongs in one of the other shards
The sdk shard exercises the SDK's own HTTP clients against local protocol peers with no gateway in the path, so a case here fails only when the client library or its wire behavior changes. The HTTP/2 case runs a hypercorn TLS peer offering h2 and http/1.1 over ALPN, drives the sync and async httpx handlers at it with `LITELLM_HTTP2` off and on, and asserts the version both the client and the peer observed on the wire. Put a test here only when it needs no proxy or database. GitHub Actions starts a local Redis for this shard like the others, so SDK-side caching cases that need a real Redis server belong here too; a case that reaches the gateway belongs in one of the other shards
The extensions shard uses the built-in generic callback and guardrail transports. It checks callback correlation and credential exclusion, guardrail rewriting and denial, retained OpenAI consumers and A2A wire versions. CircleCI runs it on eight xlarge nodes using its file timing splitter. Each node runs its assigned files with four pytest workers, each owning its database, Redis, upstream and proxy. The dependent coverage job verifies all eight execution records against the candidate case inventory. Tests must not assume a particular set of sibling files
The extensions shard uses the built-in generic callback and guardrail transports. It checks callback correlation and credential exclusion, guardrail rewriting and denial, retained OpenAI consumers and A2A wire versions. GitHub Actions runs it with 48 pytest workers, each owning its database, Redis, upstream and proxy. The census step verifies execution against the candidate inventory. Tests must not assume a particular set of sibling files
The security shard uses four pytest workers on an xlarge machine, each with its own isolated services like every other multi-worker group. Route sweeps use a new HTTP client for every call and share only the TLS verification context, so authentication and response cookies cannot cross callers. The canary rig supplies owned OpenAI and Anthropic endpoints and synthetic credentials and rejects attempted external egress
The two mcp nodes own separate services and use the same case census as providers and extensions. Their coverage data is combined into one MCP report after both nodes finish. Each node runs the MCP gateway against SDK peers owned by each test (`_support/mcp.py`): streamable HTTP, SSE and stdio peers, an OpenAPI-spec app, and an OAuth 2.1 authorization-server double. Every peer records the requests it receives so a test can assert what reached the peer, not only what the proxy answered. The shard runs with `INTEGRATION_WORKERS` set and with `INTEGRATION_COVERAGE=1`, which starts every worker's proxy and peer under `coverage run --parallel-mode` limited to the MCP modules and stores `coverage.txt` plus an HTML report with the job artifacts. `mcp_coverage.toml` measures lines through `sys.monitoring`, because the CPython 3.12 trace function that branch measurement needs made a covered proxy take about four times as long to become ready. A test that fails because the product is wrong is skipped with `pytest.skip("BUG: <symptom>")` so the skip list in `execution.json` is the open MCP bug list
Browser contracts live in `tests/e2e/ui/tests/integrationCritical` and run only through `tests/e2e/ui/integration.config.ts`. The expected browser results are listed in `expected.json` in that directory and checked by `.circleci/scripts/verify_integration_browser.py`. The CircleCI browser shard builds the checked-out dashboard, starts the owned proxy with that build, and verifies one exact browser result without retries or skips. The default Playwright selection excludes this directory. The focused project flow asserts the submitted create and clear values, fresh SQL state and actual blocked/restored serving while preserving model restrictions
Browser contracts live in `tests/e2e/ui/tests/integrationCritical` and run only through `tests/e2e/ui/integration.config.ts`. The expected browser results are listed in `expected.json` in that directory and checked by `.github/scripts/ci/verify_integration_browser.py`. The GitHub Actions browser shard builds the checked-out dashboard, starts the owned proxy with that build, and verifies one exact browser result without retries or skips. The default Playwright selection excludes this directory. The focused project flow asserts the submitted create and clear values, fresh SQL state and actual blocked/restored serving while preserving model restrictions
Two always-on `-replica` CircleCI jobs (management, database) run their groups in replica mode, where every proxy connects through a real `litellm_writer` role and a real read-only `litellm_reader` role against the same PostgreSQL. Nothing is captured there: the job passes when the tests pass, and a write routed to the read-only reader fails the test that issued it. A deeper check runs on demand as the `routing_parity` workflow, triggered through the CircleCI API v2 pipeline endpoint on the PR branch with `{"parameters": {"routing_parity_base": "<40-hex merge-base sha>"}}`. The workflow fans out over the seven groups, and each `routing-parity-<group>` job runs its own group twice against the same test harness, once with `litellm/`, `enterprise/`, and `litellm-proxy-extras/` checked out from the base revision and once from the head, with a pytest plugin snapshotting `pg_stat_statements` into `routing-observed.json` per side. The `check` step then compares the two observations and writes `routing-diff.txt`: a statement seen on both sides fails when its role set changed, globally or for the same test (per-test capture is skipped under xdist), unless it is listed in `tests/integration/routing/either_role.json`, where each entry names the statement and a one-line reason it legitimately runs on whichever role asks for it, printed under `== either role ==`. Queries seen on only one side are listed, never failed, `pg_stat_statements` evictions and a role that never ran a statement are failures
Two `-replica` GitHub Actions suites (management, database) run their groups in replica mode, where every proxy connects through a real `litellm_writer` role and a real read-only `litellm_reader` role against the same PostgreSQL. Nothing is captured there: the job passes when the tests pass, and a write routed to the read-only reader fails the test that issued it. Routing parity support remains in the local harness, but no CI workflow runs it.

View file

@ -152,7 +152,7 @@ def owned_redis(
],
text=True,
).strip()
assert host, "CircleCI owned Redis container has no address"
assert host, "CI-owned Redis container has no address"
prefix: Final = ("docker", "exec", "redis-cache", "redis-server")
output: Final = Path(inherited.get("INTEGRATION_RESULTS_DIR", str(directory)))
output.mkdir(parents=True, exist_ok=True)

View file

@ -28,7 +28,6 @@ from tests.integration._support.provider import SharedProvider, shared_provider
from tests.integration._support.routing import RoutingPlugin
from tests.integration._support.sdk_models import build_sdk_models
from tests.integration._support.workers import gateway_profile, start_worker_stacks, worker_stack
from tests.integration.run import GITHUB_FILES
if TYPE_CHECKING:
from tests.integration._support.actors import ActorSet, GatewayClient, ManagementDatabase, ScratchResources
@ -216,11 +215,6 @@ def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item
for item in items
if item.path.is_relative_to(root) and item.path.relative_to(root).parts[0] in OWNED_DIRECTORIES
)
circleci_only: Final = tuple(
item for item in owned if item.path.relative_to(root.parents[1]).as_posix() not in GITHUB_FILES
)
if circleci_only and os.environ.get("GITHUB_ACTIONS") == "true":
raise pytest.UsageError("Integration contracts are owned by CircleCI")
for item in owned:
item.add_marker(pytest.mark.integration)
config.stash[INVENTORY] = tuple(_case_identity(item.nodeid) for item in owned)
@ -230,7 +224,7 @@ def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item
raise pytest.UsageError("Integration shard index must be within the positive shard count")
files: Final = config.stash[SHARD_FILES]
if count > 1 and files is None:
raise pytest.UsageError("Parallel integration runs require CircleCI's shard file selection")
raise pytest.UsageError("Parallel integration runs require a shard file selection")
if files is not None:
available: Final = frozenset(item.nodeid.split("::", 1)[0] for item in owned)
for file in sorted(files - available - config.stash.get(FAILED_COLLECTIONS, frozenset())):

View file

@ -79,8 +79,7 @@ def _nested_pytest_environment(environment: Mapping[str, str]) -> dict[str, str]
"INTEGRATION_COVERAGE_FILE",
"INTEGRATION_CASE_SHARDS",
"INTEGRATION_CASE_SHARD_INDEX",
"CIRCLE_NODE_TOTAL",
"CIRCLE_NODE_INDEX",
"INTEGRATION_SHARD_FILES",
"PYTEST_XDIST_WORKER",
"PYTEST_XDIST_WORKER_COUNT",
"PYTEST_XDIST_TESTRUNUID",
@ -750,8 +749,6 @@ def test_direct_and_dependent_consumers_reuse_the_same_module_parameter(tmp_path
**os.environ,
"INTEGRATION_CASE_SHARDS": "2",
"INTEGRATION_CASE_SHARD_INDEX": "1",
"CIRCLE_NODE_TOTAL": "2",
"CIRCLE_NODE_INDEX": "1",
}
),
capture_output=True,

View file

@ -26,7 +26,25 @@ sys.modules[_spec.name] = coverage # @dataclass(slots=True) rebuilds via sys.mo
_spec.loader.exec_module(coverage)
def test_integration_groups_require_exclusive_scheduled_circleci_owner(tmp_path: Path) -> None:
def _write_group_workflow(root: Path, suites: tuple[str, ...] = ("management",)) -> Path:
workflow: Final = root / ".github/workflows/test-integration.yml"
workflow.parent.mkdir(parents=True, exist_ok=True)
workflow.write_text(
yaml.safe_dump(
{
"jobs": {
"integration": {
"strategy": {"matrix": {"suite": list(suites)}},
"steps": [{"run": "bash .github/scripts/ci/run_integration.sh $SUITE"}],
}
}
}
)
)
return workflow
def test_integration_groups_require_exclusive_scheduled_github_owner(tmp_path: Path) -> None:
test_path: Final = "tests/integration/management/test_contract.py"
test_file: Final = tmp_path / test_path
test_file.parent.mkdir(parents=True)
@ -37,39 +55,18 @@ def test_integration_groups_require_exclusive_scheduled_circleci_owner(tmp_path:
)
paths, findings = coverage._integration_ownership(tmp_path)
assert not paths
assert [finding.detail for finding in findings] == ["dedicated CircleCI runner is missing"]
circle: Final = tmp_path / ".circleci/config.yml"
circle.parent.mkdir()
circle.write_text(
yaml.safe_dump(
{
"jobs": {
"integration_contracts": {
"steps": [{"run": {"command": "bash .circleci/scripts/run_integration.sh management"}}]
}
},
"workflows": {"integration": {"jobs": [{"integration_contracts": {"suite": "management"}}]}},
}
)
)
assert [finding.detail for finding in findings] == ["dedicated GitHub Actions runner is missing"]
_write_group_workflow(tmp_path)
paths, findings = coverage._integration_ownership(tmp_path)
assert paths == frozenset({test_path})
assert findings == ()
configured: Final = yaml.safe_load(circle.read_text())
configured["workflows"]["integration"]["jobs"] = [
{"integration_contracts": {"matrix": {"parameters": {"suite": ["providers"]}}}}
]
circle.write_text(yaml.safe_dump(configured))
_write_group_workflow(tmp_path, ("providers",))
_, findings = coverage._integration_ownership(tmp_path)
assert [(finding.subject, finding.detail) for finding in findings] == [
("management", "canonical integration group is not scheduled by CircleCI")
("management", "canonical integration group is not scheduled by GitHub Actions")
]
configured["workflows"]["integration"]["jobs"][0]["integration_contracts"]["matrix"]["parameters"]["suite"] = [
"management"
]
circle.write_text(yaml.safe_dump(configured))
_write_group_workflow(tmp_path)
workflow: Final = tmp_path / ".github/workflows/test.yml"
workflow.parent.mkdir(parents=True)
workflow.write_text(yaml.safe_dump({"jobs": {"tests": {"steps": [{"run": "pytest tests/integration"}]}}}))
_, findings = coverage._integration_ownership(tmp_path)
assert [(finding.subject, finding.detail) for finding in findings] == [
@ -98,12 +95,9 @@ def test_integration_groups_require_exclusive_scheduled_circleci_owner(tmp_path:
assert [(finding.subject, finding.detail) for finding in missing_invocation] == [
(github_path, "GitHub-owned integration contract has no invoking job")
]
circle_config: Final = yaml.safe_load(circle.read_text())
circle_config["jobs"]["postgres_suite"] = {"parameters": {"test_path": {"type": "string"}}}
circle_config["workflows"]["integration"]["jobs"].append({"postgres_suite": {"test_path": github_path}})
circle.write_text(yaml.safe_dump(circle_config))
_, circle_invocation = coverage._integration_ownership(tmp_path)
assert circle_invocation == ()
dedicated.write_text(yaml.safe_dump({"jobs": {"tests": {"steps": [{"run": f"pytest {github_path}"}]}}}))
_, restored_invocation = coverage._integration_ownership(tmp_path)
assert restored_invocation == ()
def _write_integration_tree(root: Path, github_path: str) -> None:
@ -117,20 +111,24 @@ def _write_integration_tree(root: Path, github_path: str) -> None:
'GROUPS: Final = MappingProxyType({"management": ("management",)})\n'
f"GITHUB_FILES: Final = frozenset({{{github_path!r}}})\n"
)
(root / ".circleci").mkdir()
(root / ".circleci/config.yml").write_text(
yaml.safe_dump(
{
"jobs": {
"integration_contracts": {
"steps": [{"run": {"command": "bash .circleci/scripts/run_integration.sh management"}}]
}
},
"workflows": {"integration": {"jobs": [{"integration_contracts": {"suite": "management"}}]}},
}
)
)
(root / ".github/workflows").mkdir(parents=True)
_write_group_workflow(root)
@pytest.mark.parametrize(
("matrix", "expected"),
(
(["management", "database"], ("management", "database")),
([], ()),
(None, ()),
("${{ inputs.suite }}", ()),
(
"${{ fromJSON(inputs.suite == 'all' && '[\"management\",\"database\"]' || format('[\"{0}\"]', inputs.suite)) }}",
("management", "database"),
),
),
)
def test_integration_matrix_accounts_for_default_suites(matrix: object, expected: tuple[str, ...]) -> None:
assert coverage._matrix_suites(matrix) == expected
@pytest.mark.parametrize(
@ -172,20 +170,18 @@ def test_an_integration_file_outside_every_group_cannot_run_on_a_github_shard(tm
assert not [finding for finding in before if finding.subject == ungrouped]
def test_circleci_postgres_suites_count_their_test_path_as_invoked() -> None:
def test_workflow_matrices_count_their_test_paths_as_invoked() -> None:
config: Final = {
"workflows": {
"integration": {
"jobs": [
{"postgres_suite": {"name": "proxy-behavior", "test_path": "tests/proxy_behavior", "seed": True}},
{
"postgres_suite": {
"name": "roi-database",
"test_path": "tests/integration/database/test_roi_observed.py",
"seed": False,
}
},
]
"jobs": {
"postgres": {
"strategy": {
"matrix": {
"include": [
{"test-path": "tests/proxy_behavior"},
{"test-path": "tests/integration/database/test_roi_observed.py"},
]
}
}
}
}
}
@ -194,15 +190,27 @@ def test_circleci_postgres_suites_count_their_test_path_as_invoked() -> None:
)
def test_every_circleci_postgres_suite_is_credited_on_the_repo_as_it_stands() -> None:
circle: Final = yaml.safe_load(coverage.CIRCLECI_CONFIG.read_text())
paths: Final = tuple(
job["postgres_suite"]["test_path"]
for job in circle["workflows"]["integration"]["jobs"]
if isinstance(job, dict) and "postgres_suite" in job
def test_allowlisting_an_unrun_integration_contract_does_not_claim_it_runs(tmp_path: Path) -> None:
path: Final = "tests/integration/database/test_roi_observed.py"
_write_integration_tree(tmp_path, path)
paths, findings = coverage._integration_ownership(tmp_path)
assert path not in paths
assert [(finding.subject, finding.detail) for finding in findings] == [
(path, "GitHub-owned integration contract has no invoking job")
]
allowlist: Final = coverage.Allowlist(
test_paths=(coverage.AllowEntry(paths=(path,), reason="ran only in CircleCI, removed"),), dockerfiles=()
)
assert paths == ("tests/integration/database/test_roi_observed.py",)
assert set(paths) <= coverage._invoked_test_tokens(coverage._all_scalars())
allowed_paths, allowed_findings = coverage._integration_ownership(tmp_path, allowlist=allowlist)
assert path not in allowed_paths
assert allowed_findings == ()
(tmp_path / ".github/workflows/test-unit.yml").write_text(
yaml.safe_dump({"jobs": {"tests": {"steps": [{"run": f"pytest {path}"}]}}})
)
_, wrong_owner = coverage._integration_ownership(tmp_path, allowlist=allowlist)
assert [(finding.subject, finding.detail) for finding in wrong_owner] == [
(path, "GitHub-owned integration contract runs outside a dedicated integration workflow")
]
def test_an_ancestor_directory_covers_a_file_but_does_not_name_it():
@ -419,7 +427,7 @@ def test_an_excluded_term_matching_only_an_inner_name_leaves_the_file_claimed():
assert slice_.claims("tests/x/test_router.py", frozenset({"test_cache_key"})) is True
def test_character_class_globs_match_the_letter_shards_circleci_uses():
def test_character_class_globs_match_letter_shards():
# tests/local_testing is split by first letter; without character-class support every
# file in it looks unglobbed, and the slice guard would report the whole directory.
glob = "tests/local_testing/**/test_[a-mA-M]*.py"
@ -509,12 +517,15 @@ def test_a_workflow_that_names_a_file_clears_it_from_the_slice_check():
assert any(coverage._token_covers(token, "tests/unit/proxy/test_proxy_server_custom_logger.py") for token in named)
def test_the_slice_check_credits_only_workflows_never_the_circleci_config():
named = coverage._workflow_named_tokens()
circleci_only = "tests/proxy_admin_ui_tests"
assert not any(coverage._token_covers(token, f"{circleci_only}/test_key_management.py") for token in named), (
"a tree only CircleCI globs must not be credited to a workflow"
)
def test_the_slice_check_ignores_configs_outside_the_workflow_directory(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
workflows: Final = tmp_path / "workflows"
workflows.mkdir()
(workflows / "test.yml").write_text("jobs:\n test:\n steps:\n - run: pytest tests/selected\n")
(tmp_path / "ignored.yml").write_text("jobs:\n test:\n steps:\n - run: pytest tests/unrun\n")
monkeypatch.setattr(coverage, "WORKFLOW_DIR", workflows)
assert coverage._workflow_named_tokens() == frozenset({"tests/selected"})
@pytest.mark.parametrize("selector", ("test_selected.py", "test_selected.py::test_redis_auth"))
@ -527,7 +538,6 @@ def test_a_workflow_does_not_credit_a_file_it_never_names(
f"jobs:\n test:\n steps:\n - run: uv run pytest tests/local_testing/{selector}\n"
)
monkeypatch.setattr(coverage, "WORKFLOW_DIR", workflows)
monkeypatch.setattr(coverage, "CIRCLECI_CONFIG", tmp_path / "circleci.yml")
named: Final = coverage._workflow_named_tokens()
assert named == frozenset({"tests/local_testing/test_selected.py"})
@ -543,16 +553,14 @@ def test_slice_audit_rechecks_changed_and_new_files_between_calls(
workflows.mkdir(parents=True)
tests: Final = tmp_path / "tests/local_testing"
tests.mkdir(parents=True)
circle: Final = tmp_path / ".circleci/config.yml"
circle.parent.mkdir()
circle.write_text(
(workflows / "test.yml").write_text(
yaml.safe_dump(
{
"jobs": {
selector: {
"steps": [
{
"run": f'circleci tests glob "tests/local_testing/test_*.py" | xargs pytest -k "{selector}"'
"run": f'pytest tests/local_testing/test_*.py -k "{selector}"'
}
]
}
@ -564,7 +572,6 @@ def test_slice_audit_rechecks_changed_and_new_files_between_calls(
monkeypatch.setattr(coverage, "REPO_ROOT", tmp_path)
monkeypatch.setattr(coverage, "TESTS_ROOT", tmp_path / "tests")
monkeypatch.setattr(coverage, "WORKFLOW_DIR", workflows)
monkeypatch.setattr(coverage, "CIRCLECI_CONFIG", circle)
allowlist: Final = coverage.Allowlist((), ())
existing: Final = tests / "test_existing.py"
existing.write_text("def test_selected(): pass\n")

View file

@ -1,6 +1,6 @@
"""Regression tests for CircleCI change-based job gating.
"""Regression tests for change-based job gating.
`.circleci/scripts/classify_changes.sh` is the pure decision function behind
`.github/scripts/ci/classify_changes.sh` is the pure decision function behind
`path_filter.sh`: given the list of files a PR changed (on stdin) and a job
category, it prints `run` or `skip`. The gating contract we lock in here:
@ -24,7 +24,7 @@ from pathlib import Path
import pytest
SCRIPTS_DIR = Path(__file__).resolve().parents[2] / ".circleci" / "scripts"
SCRIPTS_DIR = Path(__file__).resolve().parents[2] / ".github" / "scripts" / "ci"
SCRIPT = SCRIPTS_DIR / "classify_changes.sh"
PATH_FILTER = SCRIPTS_DIR / "path_filter.sh"
@ -56,7 +56,7 @@ CI = [".github/workflows/test-unit.yml"]
("mcp-dependencies", ["litellm-proxy-extras/pyproject.toml"], "run"),
("mcp-dependencies", ["scripts/check_mcp_sdk_install.py"], "run"),
("mcp-dependencies", [".github/workflows/test-mcp-dependency-resolution.yml"], "run"),
("mcp-dependencies", [".circleci/scripts/classify_changes.sh"], "run"),
("mcp-dependencies", [".github/scripts/ci/classify_changes.sh"], "run"),
("mcp-dependencies", ["litellm/llms/openai/chat/gpt_transformation.py"], "skip"),
("mcp-dependencies", DOCS + CLIENT, "skip"),
("windows-release", ["litellm-rust/crates/inference/src/lib.rs"], "run"),
@ -65,7 +65,7 @@ CI = [".github/workflows/test-unit.yml"]
("windows-release", ["pyproject.toml"], "run"),
("windows-release", ["uv.lock"], "run"),
("windows-release", ["tests/windows_tests/check_windows_wheel_install.py"], "run"),
("windows-release", [".circleci/config.yml"], "run"),
("windows-release", [".github/scripts/ci/classify_changes.sh"], "run"),
("windows-release", ["litellm/main.py"], "skip"),
("windows-release", ["tests/unit/test_utils.py"], "skip"),
("windows-release", ["ui/litellm-dashboard/src/App.tsx"], "skip"),
@ -126,7 +126,7 @@ def test_classify_decisions(category: str, changed: list[str], expected: str) ->
(".github/workflows/test-unit.yml", "run"),
("tests/integration/run.py", "run"),
("uv.lock", "run"),
(".circleci/config.yml", "skip"),
(".github/workflows/test-integration.yml", "skip"),
("litellm/router.py", "skip"),
("docs/my-website/redis.md", "skip"),
),
@ -158,7 +158,7 @@ def test_markdown_under_dot_github_is_still_docs() -> None:
def test_ui_and_client_diverge_on_a_backend_only_change() -> None:
"""`client` gates CircleCI's dashboard end-to-end jobs, which drive a real
"""`client` gates the dashboard end-to-end jobs, which drive a real
proxy and so must run on backend changes. `ui` gates the dashboard build and
its unit tests, which cannot see the backend at all."""
assert classify("client", BACKEND) == "run"
@ -202,16 +202,9 @@ def _pr_repo(tmp_path: Path, feature_files: dict[str, str]) -> Path:
def _run_path_filter(work: Path, tmp_path: Path, category: str, scripts_dir: Path, is_pr: bool = True):
bin_dir = tmp_path / "bin"
bin_dir.mkdir(exist_ok=True)
stub = bin_dir / "circleci-agent"
stub.write_text("#!/usr/bin/env bash\necho \"[stub] circleci-agent $*\"\nexit 0\n")
stub.chmod(0o755)
env = dict(os.environ)
env["PATH"] = f"{bin_dir}{os.pathsep}{env['PATH']}"
env.pop("CIRCLE_PULL_REQUEST", None)
if is_pr:
env["CIRCLE_PULL_REQUEST"] = "https://github.com/x/y/pull/1"
env["GITHUB_EVENT_NAME"] = "pull_request" if is_pr else "push"
env["GITHUB_OUTPUT"] = str(tmp_path / "output")
return subprocess.run(
["bash", str(scripts_dir / "path_filter.sh"), category],
cwd=work,
@ -221,11 +214,12 @@ def _run_path_filter(work: Path, tmp_path: Path, category: str, scripts_dir: Pat
)
def test_path_filter_halts_docs_only_pr(tmp_path: Path) -> None:
def test_path_filter_skips_docs_only_pr(tmp_path: Path) -> None:
work = _pr_repo(tmp_path, {"README.md": "# docs\n"})
result = _run_path_filter(work, tmp_path, "backend", SCRIPTS_DIR)
assert result.returncode == 0
assert "circleci-agent step halt" in result.stdout
assert "decision=skip" in result.stdout
assert (tmp_path / "output").read_text() == "decision=skip\n"
def test_path_filter_runs_backend_pr(tmp_path: Path) -> None:

View file

@ -1,310 +0,0 @@
import os
import shutil
import subprocess
from collections.abc import Iterator, Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import Final
import pytest
import yaml
from pydantic import BaseModel, ConfigDict
CIRCLECI: Final = Path(__file__).resolve().parents[2] / ".circleci"
CONFIG: Final = CIRCLECI / "config.yml"
PUBLIC_REPO: Final = "https://github.com/BerriAI/litellm.git"
LINUX_SHELL: Final = ("bash", "-eo", "pipefail", "-c")
WINDOWS_SHELL: Final = ("bash", "-c")
PRODUCT_DIRS: Final = ("litellm", "enterprise", "litellm-proxy-extras")
class _Run(BaseModel):
model_config = ConfigDict(frozen=True)
command: str
class _Step(BaseModel):
model_config = ConfigDict(frozen=True)
run: _Run
class _Command(BaseModel):
model_config = ConfigDict(frozen=True)
steps: tuple[_Step]
class _Commands(BaseModel):
model_config = ConfigDict(frozen=True)
checkout_over_https: _Command
class _NamedRun(BaseModel):
model_config = ConfigDict(frozen=True)
name: str = ""
command: str
class _JobStep(BaseModel):
model_config = ConfigDict(frozen=True)
run: _NamedRun | str | None = None
class _Job(BaseModel):
model_config = ConfigDict(frozen=True)
steps: tuple[_JobStep | str, ...]
class _Jobs(BaseModel):
model_config = ConfigDict(frozen=True)
routing_parity: _Job
class _Config(BaseModel):
model_config = ConfigDict(frozen=True)
commands: _Commands
jobs: _Jobs
class _TraceEvent(BaseModel):
model_config = ConfigDict(frozen=True)
event: str
name: str = ""
@dataclass(frozen=True, slots=True)
class _Upstream:
url: str
older: str
newer: str
def _step_name(step: object) -> str:
if isinstance(step, str):
return step
if isinstance(step, dict) and len(step) == 1:
return str(next(iter(step)))
return ""
def _step_names(node: object) -> Iterator[str]:
if isinstance(node, dict):
for key, value in node.items():
if key in ("steps", "pre-steps", "post-steps") and isinstance(value, list):
yield from map(_step_name, value)
yield from _step_names(value)
if isinstance(node, list):
for item in node:
yield from _step_names(item)
def _config() -> _Config:
return _Config.model_validate(yaml.safe_load(CONFIG.read_text()))
def _routing_parity_step(name: str) -> str:
return next(
step.run.command
for step in _config().jobs.routing_parity.steps
if isinstance(step, _JobStep) and isinstance(step.run, _NamedRun) and step.run.name == name
)
def _fetches(trace: Path) -> int:
events: Final = tuple(map(_TraceEvent.model_validate_json, trace.read_text().splitlines()))
return sum(event.event == "cmd_name" and event.name == "fetch" for event in events)
def _git(cwd: Path, *args: str) -> str:
return subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True, text=True).stdout.strip()
def _commit(work: Path, files: Mapping[str, str], message: str) -> str:
for name, content in files.items():
(work / name).parent.mkdir(parents=True, exist_ok=True)
(work / name).write_text(content)
_git(work, "add", "-A")
_git(work, "commit", "-q", "--allow-empty", "-m", message)
return _git(work, "rev-parse", "HEAD")
def _new_upstream(tmp_path: Path) -> tuple[Path, Path]:
remote: Final = tmp_path / "remote.git"
work: Final = tmp_path / "work"
work.mkdir()
_git(tmp_path, "init", "-q", "--bare", str(remote))
_git(remote, "config", "uploadpack.allowFilter", "true")
_git(work, "init", "-q", "-b", "main")
_git(work, "config", "user.email", "t@t")
_git(work, "config", "user.name", "t")
return remote, work
def _publish_two_commits(tmp_path: Path) -> _Upstream:
remote, work = _new_upstream(tmp_path)
older: Final = _commit(work, {"app.py": "x = 1\n"}, "pipeline commit")
newer: Final = _commit(work, {"app.py": "x = 2\n"}, "main moved on")
_git(work, "push", "-q", str(remote), "main")
return _Upstream(url=remote.as_uri(), older=older, newer=newer)
def _publish_docs_only_pr(tmp_path: Path) -> _Upstream:
remote, work = _new_upstream(tmp_path)
main: Final = _commit(work, {"litellm_inference.py": "x\n"}, "base")
_git(work, "checkout", "-q", "-b", "litellm_feature")
head: Final = _commit(work, {"README.md": "# docs\n"}, "docs")
_git(work, "push", "-q", str(remote), "main", "litellm_feature")
return _Upstream(url=remote.as_uri(), older=main, newer=head)
def _product(version: str) -> dict[str, str]:
return {f"{directory}/code.py": f"{version} {directory}\n" for directory in PRODUCT_DIRS}
def _publish_parity_base(tmp_path: Path, base_on_main: bool) -> _Upstream:
remote, work = _new_upstream(tmp_path)
fork_point: Final = _commit(work, _product("fork point"), "fork point")
base: Final = _commit(work, _product("base"), "release")
_git(work, "branch", "-q", "stable")
_git(work, "reset", "-q", "--hard", base if base_on_main else fork_point)
head: Final = _commit(work, _product("head"), "pipeline commit")
_git(work, "push", "-q", str(remote), "main", "stable")
return _Upstream(url=remote.as_uri(), older=base, newer=head)
def _stub(bin_dir: Path, name: str, script: str) -> None:
bin_dir.mkdir(exist_ok=True)
(bin_dir / name).write_text(f"#!/usr/bin/env bash\n{script}\n")
(bin_dir / name).chmod(0o755)
def _job_env(tmp_path: Path, upstream: _Upstream) -> dict[str, str]:
return {
"PATH": f"{tmp_path / 'bin'}{os.pathsep}{os.environ['PATH']}",
"HOME": str(tmp_path),
"GIT_CONFIG_NOSYSTEM": "1",
"GIT_TERMINAL_PROMPT": "0",
"GIT_SSH_COMMAND": "false",
"GIT_CONFIG_COUNT": "1",
"GIT_CONFIG_KEY_0": f"url.{upstream.url}.insteadOf",
"GIT_CONFIG_VALUE_0": PUBLIC_REPO,
}
def _run_checkout(
tmp_path: Path,
upstream: _Upstream,
revisions: Mapping[str, str],
shell: tuple[str, ...] = LINUX_SHELL,
) -> subprocess.CompletedProcess[str]:
job: Final = tmp_path / "job"
job.mkdir()
command: Final = _config().commands.checkout_over_https
return subprocess.run(
[*shell, command.steps[0].run.command],
cwd=job,
env={**_job_env(tmp_path, upstream), **revisions},
capture_output=True,
text=True,
)
def test_every_clone_goes_over_https_so_no_job_needs_the_ssh_checkout_key() -> None:
names: Final = tuple(_step_names(yaml.safe_load(CONFIG.read_text())))
assert "checkout_over_https" in names
assert "checkout" not in names
@pytest.mark.parametrize(
"revisions",
(
{"CIRCLE_SHA1": "older"},
{"CIRCLE_SHA1": "older", "REQUESTED_REVISION": ""},
{"CIRCLE_SHA1": "newer", "REQUESTED_REVISION": "older"},
),
)
def test_a_job_checks_out_its_own_commit_from_the_public_https_url_after_main_moves(
tmp_path: Path, revisions: Mapping[str, str]
) -> None:
upstream: Final = _publish_two_commits(tmp_path)
shas: Final = {"older": upstream.older, "newer": upstream.newer, "": ""}
result: Final = _run_checkout(tmp_path, upstream, {name: shas[value] for name, value in revisions.items()})
assert result.returncode == 0, result.stderr
assert _git(tmp_path / "job", "rev-parse", "HEAD") == upstream.older
@pytest.mark.parametrize("shell", (LINUX_SHELL, WINDOWS_SHELL), ids=("linux", "windows-bash-exe"))
def test_a_revision_that_is_not_a_full_sha_is_refused_before_git_runs(tmp_path: Path, shell: tuple[str, ...]) -> None:
upstream: Final = _publish_two_commits(tmp_path)
result: Final = _run_checkout(
tmp_path, upstream, {"CIRCLE_SHA1": upstream.newer, "REQUESTED_REVISION": "main"}, shell
)
assert result.returncode != 0
assert not (tmp_path / "job" / ".git").exists()
def test_a_docs_only_pr_job_still_skips_backend_work_after_the_checkout(tmp_path: Path) -> None:
upstream: Final = _publish_docs_only_pr(tmp_path)
_stub(tmp_path / "bin", "circleci-agent", 'echo "circleci-agent $*"')
checkout: Final = _run_checkout(tmp_path, upstream, {"CIRCLE_SHA1": upstream.newer})
assert checkout.returncode == 0, checkout.stderr
path_filter: Final = subprocess.run(
["bash", str(CIRCLECI / "scripts" / "path_filter.sh"), "backend"],
cwd=tmp_path / "job",
env={**_job_env(tmp_path, upstream), "CIRCLE_PULL_REQUEST": "https://github.com/BerriAI/litellm/pull/1"},
capture_output=True,
text=True,
)
assert path_filter.returncode == 0, path_filter.stderr
assert "circleci-agent step halt" in path_filter.stdout
@pytest.mark.parametrize("shell", (LINUX_SHELL, WINDOWS_SHELL), ids=("linux", "windows-bash-exe"))
@pytest.mark.parametrize(
("failing_step", "failures", "checkouts", "checked_out"),
(("fetch", 2, 1, True), ("fetch", 3, 0, False), ("checkout", 2, 3, True), ("checkout", 3, 3, False)),
)
def test_a_failed_fetch_or_blob_download_is_retried_and_the_step_stops_on_its_error_once_retries_run_out(
tmp_path: Path, shell: tuple[str, ...], failing_step: str, failures: int, checkouts: int, checked_out: bool
) -> None:
upstream: Final = _publish_two_commits(tmp_path)
calls: Final = tmp_path / "calls"
_stub(tmp_path / "bin", "sleep", f'echo sleep >> "{calls}"')
_stub(
tmp_path / "bin",
"git",
f'''echo "$1" >> "{calls}"
if [ "$1" = {failing_step} ] && [ "$(grep -c '^{failing_step}$' "{calls}")" -le {failures} ]; then exit 128; fi
exec "{shutil.which("git")}" "$@"''',
)
result: Final = _run_checkout(tmp_path, upstream, {"CIRCLE_SHA1": upstream.older}, shell)
assert (result.returncode == 0) is checked_out, result.stderr
steps: Final = calls.read_text().splitlines()
assert steps.count("fetch") == min(failures + 1, 3)
assert steps.count("sleep") == steps.count("fetch") - 1
assert steps.count("checkout") == checkouts
head: Final = subprocess.run(["git", "rev-parse", "HEAD"], cwd=tmp_path / "job", capture_output=True, text=True)
assert (head.stdout.strip() == upstream.older) is checked_out
@pytest.mark.parametrize("base_on_main", (True, False), ids=("main-commit", "release-branch-commit"))
def test_routing_parity_downloads_the_base_files_in_one_fetch_instead_of_one_per_missing_file(
tmp_path: Path, base_on_main: bool
) -> None:
upstream: Final = _publish_parity_base(tmp_path, base_on_main)
checkout: Final = _run_checkout(tmp_path, upstream, {"CIRCLE_SHA1": upstream.newer})
assert checkout.returncode == 0, checkout.stderr
job: Final = tmp_path / "job"
(job / "litellm" / "rust_bridge").mkdir()
(job / "litellm" / "rust_bridge" / "_native.abi3.so").write_text("built for this job\n")
trace: Final = tmp_path / "trace.json"
result: Final = subprocess.run(
[*LINUX_SHELL, _routing_parity_step("Check out base product code")],
cwd=job,
env={**_job_env(tmp_path, upstream), "ROUTING_PARITY_BASE": upstream.older, "GIT_TRACE2_EVENT": str(trace)},
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stderr
assert tuple((job / name).read_text() for name in _product("base")) == tuple(_product("base").values())
assert _fetches(trace) == 1
assert _git(job, "rev-parse", "HEAD") == upstream.newer

View file

@ -1,36 +0,0 @@
import importlib.util
from pathlib import Path
from typing import Final
import pytest
SCRIPT: Final = Path(__file__).resolve().parents[2] / ".circleci/scripts/run_migration_tests.py"
SPEC: Final = importlib.util.spec_from_file_location("migration_ci", SCRIPT)
assert SPEC is not None and SPEC.loader is not None
MODULE: Final = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(MODULE)
@pytest.mark.parametrize(
"xml,expected,exit_code,passed",
(
('<testsuites><testsuite><testcase name="a"/><testcase name="b"/></testsuite></testsuites>', 2, 0, True),
('<testsuites><testsuite><testcase name="a"/></testsuite></testsuites>', 2, 0, False),
("<testsuite><testcase><failure/></testcase></testsuite>", 1, 0, False),
("<testsuite><testcase><error/></testcase></testsuite>", 1, 0, False),
("<testsuite><testcase><skipped/></testcase></testsuite>", 1, 0, False),
("<testsuite><testcase/></testsuite>", 1, 1, False),
("<testsuite><testcase/></testsuite>", 1, 5, False),
("<testsuite/>", 1, 0, False),
("<broken", 1, 0, False),
(None, 1, 0, False),
('<testsuite><testcase name="a"/><testcase name="a"/></testsuite>', 2, 0, False),
),
)
def test_only_a_complete_passing_suite_can_certify_an_image(
tmp_path: Path, xml: str | None, expected: int, exit_code: int, passed: bool
) -> None:
path: Final = tmp_path / "results.xml"
if xml is not None:
path.write_text(xml)
assert MODULE.successful_junit(path, expected, exit_code) is passed

View file

@ -1,177 +0,0 @@
"""Static guardrails for how CircleCI provisions Rust.
The root package builds `litellm-rust` through maturin, so any job that runs
`uv sync` or `uv build` compiles the bridge. The `cimg/python` images ship no
Rust toolchain, and when cargo is missing maturin's `puccinialin` helper
quietly provisions one itself: it fetches `rustup-init` from the unversioned
`https://static.rust-lang.org/rustup/dist/<triple>/` path with no checksum and
installs a floating `stable` toolchain. uv suppresses build-backend output on a
successful sync, so that happens with nothing in the job log to show for it,
and the compiler a job builds with changes whenever upstream publishes.
Two invariants are pinned here:
1. No step list (job or reusable command) reaches a `uv sync` / `uv build`
without a Rust toolchain already provisioned ahead of it. That is the
`install_rust` command on Linux and `install_windows_toolchain` on Windows,
so the check accepts any command or step that installs a pinned rustup. A new job that syncs without one
falls back to the unpinned path, which is exactly the regression a static
check catches at PR time and a green CI run does not.
2. Both installers pin what they download: an explicit rustup version, a
verified SHA-256, and the exact toolchain in `rust-toolchain.toml`.
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import Final
import pytest
import yaml
REPO_ROOT = Path(__file__).resolve().parents[2]
CONFIG = REPO_ROOT / ".circleci" / "config.yml"
TOOLCHAIN: Final = REPO_ROOT / "rust-toolchain.toml"
BUILDS_WORKSPACE = re.compile(r"\buv\s+(?:sync|build)\b")
RUSTUP_ARCHIVE_URL = re.compile(r"https://static\.rust-lang\.org/rustup/archive/\d+\.\d+\.\d+/")
EXACT_TOOLCHAIN = re.compile(r"--default-toolchain\s+\"?(\d+\.\d+\.\d+)\"?")
TOOLCHAIN_CHANNEL: Final = re.compile(r'^channel = "(\d+\.\d+\.\d+)"$', re.MULTILINE)
def _config() -> dict[str, object]:
return yaml.safe_load(CONFIG.read_text())
def _step_text(step: object) -> str:
"""Flatten one step into the shell text it runs, or '' for a command reference."""
if isinstance(step, dict):
run = step.get("run")
if isinstance(run, str):
return run
if isinstance(run, dict):
command = run.get("command")
return command if isinstance(command, str) else ""
return ""
def _pinned_toolchain() -> str:
match: Final = TOOLCHAIN_CHANNEL.search(TOOLCHAIN.read_text())
assert match is not None, "rust-toolchain.toml must pin an exact channel"
return match.group(1)
def _without_comments(text: str) -> str:
return "\n".join(line for line in text.splitlines() if not line.lstrip().startswith("#"))
def _installs_pinned_rustup(step: object) -> bool:
text = _step_text(step)
return "rustup-init" in text and ("sha256sum" in text or "SHA256" in text)
def _provisioning_commands() -> frozenset[str]:
return frozenset(
name.removeprefix("command ")
for name, steps in _step_lists().items()
if name.startswith("command ") and any(_installs_pinned_rustup(step) for step in steps)
)
def _provisions_rust(step: object, provisioning_commands: frozenset[str]) -> bool:
return (isinstance(step, str) and step in provisioning_commands) or _installs_pinned_rustup(step)
def _step_lists() -> dict[str, list[object]]:
config = _config()
lists: dict[str, list[object]] = {}
for kind in ("jobs", "commands"):
section = config.get(kind)
if not isinstance(section, dict):
continue
for name, body in section.items():
steps = body.get("steps") if isinstance(body, dict) else None
if isinstance(steps, list):
lists[f"{kind[:-1]} {name}"] = steps
return lists
def _first_unprovisioned_build(steps: list[object], provisioning_commands: frozenset[str]) -> str | None:
"""Return the shell text of the first workspace build reached without Rust, if any."""
rust_ready = False
for step in steps:
if _provisions_rust(step, provisioning_commands):
rust_ready = True
text = _step_text(step)
if BUILDS_WORKSPACE.search(_without_comments(text)) and not rust_ready:
return text
return None
def test_step_lists_exist() -> None:
lists = _step_lists()
assert "command install_rust" in lists
building = {
name
for name, steps in lists.items()
if any(BUILDS_WORKSPACE.search(_without_comments(_step_text(s))) for s in steps)
}
assert "command setup_litellm_test_deps" in building, f"found {sorted(building)}"
def test_no_workspace_build_without_a_provisioned_rust_toolchain() -> None:
provisioning_commands: Final = _provisioning_commands()
assert {"install_rust", "install_windows_toolchain"} <= provisioning_commands
offenders = {
name: build
for name, steps in _step_lists().items()
if (build := _first_unprovisioned_build(steps, provisioning_commands)) is not None
}
assert not offenders, (
"these CircleCI step lists run `uv sync`/`uv build` with no Rust toolchain provisioned first, "
"so maturin will download an unpinned rustup and a floating toolchain instead: "
f"{ {name: build.strip().splitlines()[0] for name, build in offenders.items()} }"
)
@pytest.fixture(name="install_rust_command")
def _install_rust_command() -> str:
steps = _step_lists()["command install_rust"]
return "\n".join(_step_text(step) for step in steps)
def test_install_rust_pins_the_rustup_version_in_the_url(install_rust_command: str) -> None:
assert RUSTUP_ARCHIVE_URL.search(install_rust_command), (
"install_rust must download rustup-init from a version-pinned /rustup/archive/<x.y.z>/ URL; "
"the /rustup/dist/ path always serves whatever rustup is current"
)
assert "/rustup/dist/" not in install_rust_command
def test_install_rust_verifies_the_installer_checksum(install_rust_command: str) -> None:
assert "sha256sum -c" in install_rust_command
assert re.search(r"RUSTUP_SHA256=[0-9a-f]{64}\b", install_rust_command), (
"install_rust must compare the downloaded installer against a hardcoded SHA-256 "
"taken from rust-lang's published .sha256 sidecar"
)
checksum_index = install_rust_command.index("sha256sum -c")
execute_index = install_rust_command.index("/tmp/rustup-init -y")
assert checksum_index < execute_index, "the checksum must be verified before the installer is executed"
def test_install_rust_pins_an_exact_toolchain_version(install_rust_command: str) -> None:
match: Final = EXACT_TOOLCHAIN.search(install_rust_command)
assert match is not None, (
"install_rust must pin an exact toolchain version (e.g. 1.98.0); a channel name like "
"stable/beta/nightly makes the compiler drift with whatever upstream published that day"
)
assert match.group(1) == _pinned_toolchain()
def test_windows_installer_matches_the_repo_toolchain() -> None:
windows_steps: Final = _step_lists()["command install_windows_toolchain"]
windows_command: Final = "\n".join(_step_text(step) for step in windows_steps)
match: Final = EXACT_TOOLCHAIN.search(windows_command)
assert match is not None
assert match.group(1) == _pinned_toolchain()

View file

@ -355,6 +355,35 @@ def test_checked_in_pins_hold_in_the_checked_in_cost_map() -> None:
assert guard.pin_failures(pins, cost_map) == ()
@pytest.mark.parametrize("pins_path", guard.PINS_PATHS)
@pytest.mark.parametrize("pins", (PINS, "", "not JSON"))
def test_relocated_pins_keep_the_same_validation(pins_path: str, pins: str) -> None:
cost_map: Final = _serialize(BASE_MAP)
blobs: Final = {
f"base:{guard.COST_MAP_PATH}": cost_map,
f"base:{guard.BACKUP_PATH}": cost_map,
f"head:{guard.COST_MAP_PATH}": cost_map,
f"head:{guard.BACKUP_PATH}": cost_map,
f"base:{guard.PINS_PATH}": PINS,
f"head:{pins_path}": pins,
}
def read_git(_operation: str, blob: str) -> str | None:
return blobs.get(blob)
base: Final = guard.snapshot("base", read_git=read_git)
head: Final = guard.snapshot("head", read_git=read_git)
assert base == _snapshot(BASE_MAP, pins=PINS)
assert head == _snapshot(BASE_MAP, pins=pins)
failures: Final = _failures(head, changed_files=(guard.PINS_PATH, pins_path), bot=False, base=base)
assert bool(failures) is (pins != PINS), failures
@pytest.mark.parametrize("pins_path", guard.PINS_PATHS)
def test_relocated_pins_cannot_be_changed_by_bots(pins_path: str) -> None:
assert guard.pin_author_failures("litellm_fix_pricing", "Bot", (pins_path,)) == (BOT_AUTHOR_PIN_FAILURE,)
def _commit(repo: Path, cost_map: dict[str, object], message: str, pins: str | None = None) -> str:
text = _serialize(cost_map)
pins_file = repo / guard.PINS_PATH

View file

@ -21,7 +21,7 @@ from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
SCRIPT = REPO_ROOT / ".github" / "scripts" / "detect_changes.sh"
CLASSIFIER = REPO_ROOT / ".circleci" / "scripts" / "classify_changes.sh"
CLASSIFIER = REPO_ROOT / ".github" / "scripts" / "ci" / "classify_changes.sh"
UI_FILE = "ui/litellm-dashboard/src/components/Teams.tsx"
BACKEND_FILE = "litellm/proxy/proxy_server.py"
@ -65,9 +65,9 @@ def _scripts_tree(tmp_path: Path, classifier_body: str | None = None) -> Path:
"""Copy the scripts into a throwaway tree, preserving their relative layout."""
root = tmp_path / "tree"
(root / ".github" / "scripts").mkdir(parents=True)
(root / ".circleci" / "scripts").mkdir(parents=True)
(root / ".github" / "scripts" / "ci").mkdir(parents=True)
shutil.copy(SCRIPT, root / ".github" / "scripts" / SCRIPT.name)
target = root / ".circleci" / "scripts" / CLASSIFIER.name
target = root / ".github" / "scripts" / "ci" / CLASSIFIER.name
if classifier_body is None:
shutil.copy(CLASSIFIER, target)
else:

View file

@ -145,7 +145,7 @@ def test_banner_silent_when_vcr_disabled(monkeypatch, health_reset, patch_capaci
# ---------------------------------------------------------------------------
# Diagnostic-log dedup + cap. CircleCI truncates step output to the last
# Diagnostic-log dedup + cap. GitHub Actions truncates step output to the last
# ~400 KB; an unbounded diagnostic dump pushes the VCR classification summary
# out of the retrievable window, so the dump must dedupe and cap.
# ---------------------------------------------------------------------------