mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-12 08:42:18 +00:00
ci: remove CircleCI (#50950)
This commit is contained in:
parent
2c44f26288
commit
a6ce922f07
42 changed files with 294 additions and 2663 deletions
|
|
@ -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
|
||||
1785
.circleci/config.yml
1785
.circleci/config.yml
File diff suppressed because it is too large
Load diff
|
|
@ -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())
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
docs
|
||||
litellm/proxy/discovery_endpoints/skill_scanner_adapter
|
||||
.circleci
|
||||
.github
|
||||
tests
|
||||
.git
|
||||
|
|
|
|||
5
.github/AGENTS.md
vendored
Normal file
5
.github/AGENTS.md
vendored
Normal 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`.
|
||||
2
.github/actions/detect-changes/action.yml
vendored
2
.github/actions/detect-changes/action.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
12
.github/ci-coverage-allowlist.yml
vendored
12
.github/ci-coverage-allowlist.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
103
.github/scripts/assert_ci_coverage.py
vendored
103
.github/scripts/assert_ci_coverage.py
vendored
|
|
@ -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 ""
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
@ -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" \
|
||||
|
|
@ -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(
|
||||
2
.github/scripts/detect_changes.sh
vendored
2
.github/scripts/detect_changes.sh
vendored
|
|
@ -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"
|
||||
|
|
|
|||
16
.github/workflows/test-integration.yml
vendored
16
.github/workflows/test-integration.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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())):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue