mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-02 07:13:02 +00:00
* ci(circleci): clone the public repo over HTTPS so main jobs stop failing at checkout The project's SSH checkout key stopped authenticating on Oct 27, so every job's built-in checkout step failed with Permission denied (publickey). The repo is public, so each job now fetches its pipeline's commit anonymously over HTTPS, the way the migration jobs already did * fix(circleci): let the PR path filter read history after the shallow clone and test the clone step itself * fix(circleci): clone full history without file contents, retry the fetch, and record the integration schedule The depth-1 clone left the PR path filter without a merge base, and the unshallow that fixed it pulled about 1.4 GB per filtered job. A blobless fetch gives every job the history the built-in checkout step gave, so the path filter change goes back to main's version. The fetch now gets three tries, the tests run the step under the Windows job's bare bash too, and .circleci/AGENTS.md records the integration schedule's cron * fix(circleci): stop on the fetch error once the three tries run out, and name the GitHub App fallback as the cause * fix(circleci): retry the checkout's file download with the fetch, since a dropped download failed the job outside the retry * fix(circleci): fetch the routing parity base with its file contents, since the blobless clone downloaded each file separately --------- Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
310 lines
11 KiB
Python
310 lines
11 KiB
Python
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
|