litellm/tests/claude_code/cron_vm
mateo-berri 6add3c0be3 compat-matrix: fixes from VM end-to-end validation
Tier 1 (single anthropic cell) and tier 2 (full
basic_messaging_non_streaming row across 5 providers) now run cleanly
from `run_daily.sh` on the GCP VM. Five issues showed up during
validation; each is fixed in this commit.

1. uv version pin
   ----------------
   The litellm worktree pins an exact uv version in
   `pyproject.toml`'s `[tool.uv] required-version` field. The cron
   VM's system uv (currently 0.11.8) refused to sync against the
   v1.83.10-stable lockfile (which pins ==0.10.9). Fix: parse the
   pinned version out of the worktree's pyproject.toml, download the
   matching standalone binary into `<worktree>/.uv-bin/uv-<version>`,
   and use it for sync/run/proxy. Cached across runs.

2. Missing proxy + dev extras
   ---------------------------
   `uv sync --frozen` only installed the base dependency set, so
   `uv run litellm` died at startup with
   `ModuleNotFoundError: No module named 'websockets'`. Per the
   repo's AGENTS.md the canonical incantation is
   `uv sync --frozen --group proxy-dev --extra proxy`.

3. Wrong env vars for the test driver
   -----------------------------------
   The script was setting `ANTHROPIC_BASE_URL` and
   `ANTHROPIC_AUTH_TOKEN`, which is what Claude Code itself reads,
   but the test files read `LITELLM_PROXY_BASE_URL` and
   `LITELLM_PROXY_API_KEY` (search the test_config-driven test files
   for `PROXY_BASE_URL_ENV = "LITELLM_PROXY_BASE_URL"`). Tests were
   marking themselves `fail` with
   "missing required env: set LITELLM_PROXY_BASE_URL...". Fix: rename
   the two env vars in the pytest invocation. The driver still
   propagates them onward as ANTHROPIC_BASE_URL/AUTH_TOKEN to Claude.

4. Cleanup couldn't find the proxy
   --------------------------------
   The previous setup did
   `( ... && setsid uv run litellm ... ) &; PROXY_PID=$!`. `setsid`
   detaches the inner uv into its own session, but `$!` records the
   PID of the outer subshell, not the long-lived python proxy. So
   `kill -TERM "-${PROXY_PID}"` in the EXIT trap targeted the wrong
   pgid and the proxy survived as an orphan whenever the script was
   killed externally. Fix: replace the subshell with
   `setsid bash -c '...'` that writes $$ to a known pid file before
   exec'ing the proxy. The cleanup trap reads that file and uses it
   as the pgid. Belt-and-braces: the trap also `pgrep -f`s by port
   number and SIGKILLs survivors. Trap now fires on `INT TERM` too,
   not just `EXIT`.

5. .uv-bin cache survives git clean
   ---------------------------------
   The original `git clean -fdx -e .venv` wiped `.uv-bin/` between
   runs, forcing re-download of the pinned uv binary on every
   invocation. Now excluded.

Things that worked first try
----------------------------

  * Worktree clone + checkout to the resolved tag.
  * gh auth on this VM (mateo-berri account, collaborator on
    BerriAI/litellm-docs).
  * The matrix builder produced a well-formed
    `compatibility-matrix.json` with the right per-cell aggregation
    even when 4 of 5 cells failed (tier 2 was: anthropic=pass,
    bedrock_invoke=fail, bedrock_converse=fail, vertex_ai=fail with a
    real 403 from GCP for insufficient scopes, azure=fail with
    timeout).
2026-05-06 23:27:14 +00:00
..
build_matrix.py compat-matrix: replace publisher.py + resolver.py with a bash run_daily.sh 2026-05-06 23:27:14 +00:00
litellm-compat-matrix.env.example compat-matrix: run from a GCP VM via systemd; drop docker + GHA 2026-05-06 23:27:14 +00:00
litellm-compat-matrix.service compat-matrix: replace publisher.py + resolver.py with a bash run_daily.sh 2026-05-06 23:27:14 +00:00
litellm-compat-matrix.timer compat-matrix: run from a GCP VM via systemd; drop docker + GHA 2026-05-06 23:27:14 +00:00
README.md compat-matrix: replace publisher.py + resolver.py with a bash run_daily.sh 2026-05-06 23:27:14 +00:00
run_daily.sh compat-matrix: fixes from VM end-to-end validation 2026-05-06 23:27:14 +00:00

Cron VM setup for the Claude Code compatibility-matrix populator

The populator runs daily on a dedicated GCP VM (litellm-compatibility-matrix-populator) rather than as a GitHub Action. Trade-offs:

  • Real VM means we can gh auth login against an account that's already a collaborator on BerriAI/litellm-docs, instead of provisioning a GitHub App with pull-requests: write.
  • Persistent state (a single ~/litellm-cron-worktree/ and its .venv) is reused across runs, so each daily run does a fast git checkout + incremental uv sync rather than a fresh clone + cold sync.
  • No Docker dependency — the proxy runs directly via uv run litellm.
  • ⚠️ The VM has to actually be on. systemd's Persistent=true recovers from short outages, but a multi-day outage means the matrix goes stale until the VM is back.
  • ⚠️ Provider credentials live on the VM filesystem (/etc/litellm-compat-matrix.env) instead of GitHub secrets. Treat the VM as an environment with comparable blast radius to a CI runner.

Layout

File Purpose
run_daily.sh The actual cron job. Resolves versions, updates the worktree, boots the proxy, runs pytest, builds the JSON, opens (or updates) a docs PR.
build_matrix.py Tiny Python CLI that wraps tests.claude_code.matrix_builder.build_from_paths. Exists only because the bash script needs some way to render the per-cell aggregation, and the builder is already Python.
litellm-compat-matrix.service systemd oneshot that invokes run_daily.sh.
litellm-compat-matrix.timer OnCalendar=*-*-* 06:00:00 UTC, Persistent=true.
litellm-compat-matrix.env.example Template for /etc/litellm-compat-matrix.env.

What run_daily.sh does

  1. Resolves the latest LiteLLM v*-stable tag by hitting the GitHub Releases API (curl | jq).
  2. Reads the local Claude Code CLI version via claude --version. The cron does not auto-upgrade the CLI — operators do that out-of-band by running npm install -g @anthropic-ai/claude-code@latest.
  3. Updates the persistent worktree at ~/litellm-cron-worktree/: git fetch --tags --force, git reset --hard, git clean -fdx -e .venv, git checkout --force <tag>. The .venv is preserved across runs so uv sync --frozen is incremental.
  4. Boots the proxy as a setsid background process on port 4100 (so it can't collide with a developer's :4000), then polls /health/liveliness until it's up.
  5. Runs pytest with ANTHROPIC_BASE_URL pointed at the proxy and COMPAT_RESULTS_PATH set so the conftest hook writes the per-test results artifact. Test failures become fail cells in the JSON, not script errors.
  6. Builds compatibility-matrix.json by handing the artifact + manifest to build_matrix.py.
  7. Opens or updates a docs PR: gh repo clone of litellm-docs into a tempdir, deterministic head branch (compat-matrix/<litellm-version>-<claude-code-version>-<UTC-date>), --force-with-lease push, gh pr create. A re-run on the same day fast-forwards the existing branch and gh pr create no-ops ("a pull request for branch ... already exists" is treated as success).

One-time VM setup

Run as mateo on the cron VM:

# 1. Toolchain
sudo apt-get update
sudo apt-get install -y git nodejs npm jq curl
curl -LsSf https://astral.sh/uv/install.sh | sh
sudo apt-get install -y gh   # or follow https://cli.github.com/

# 2. Claude Code CLI (the cron does NOT auto-upgrade this; rerun this
#    line out-of-band when you want a fresh CLI to be tested)
sudo npm install -g @anthropic-ai/claude-code@latest

# 3. Litellm checkout. Used by systemd's WorkingDirectory and as the
#    source of the .service / .timer files. The cron itself runs out
#    of the separate worktree at ~/litellm-cron-worktree/.
mkdir -p ~/litellm
git clone https://github.com/BerriAI/litellm.git ~/litellm/litellm

# 4. gh auth — must be a collaborator on BerriAI/litellm-docs.
gh auth login   # follow prompts; pick HTTPS + token paste flow

# 5. Provider credentials.
sudo cp ~/litellm/litellm/tests/claude_code/cron_vm/litellm-compat-matrix.env.example \
        /etc/litellm-compat-matrix.env
sudoedit /etc/litellm-compat-matrix.env   # fill in real values
sudo chmod 0600 /etc/litellm-compat-matrix.env

# 6. systemd units.
sudo cp ~/litellm/litellm/tests/claude_code/cron_vm/litellm-compat-matrix.service /etc/systemd/system/
sudo cp ~/litellm/litellm/tests/claude_code/cron_vm/litellm-compat-matrix.timer   /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now litellm-compat-matrix.timer

Operating it

# When does it run next?
systemctl list-timers litellm-compat-matrix.timer

# Trigger a real run right now (PRs to litellm-docs).
sudo systemctl start litellm-compat-matrix.service

# Trigger a run that does NOT open a PR (good for first-time validation).
SKIP_PUBLISH=1 ~/litellm/litellm/tests/claude_code/cron_vm/run_daily.sh

# Narrow to one cell while debugging.
SKIP_PUBLISH=1 PYTEST_K='basic_messaging_non_streaming and anthropic' \
  ~/litellm/litellm/tests/claude_code/cron_vm/run_daily.sh

# Watch the most recent run.
journalctl -u litellm-compat-matrix.service -f

# Read older runs.
journalctl -u litellm-compat-matrix.service --since '2 days ago'

# Disable until further notice (e.g. while debugging).
sudo systemctl disable --now litellm-compat-matrix.timer

Gotchas

  • The proxy port is 4100, not 4000. This is so a developer SSH'd into the same VM with their own :4000 proxy doesn't collide with a cron run. Override with PROXY_PORT=... in /etc/litellm-compat-matrix.env if you need to.
  • uv sync --frozen requires the resolved tag to be tagged on GitHub. If the latest stable release was made but not pushed as a git tag, the git checkout step fails. Push the tag, then rerun.
  • gh auth token rotation is your problem. The cron does not refresh the token; if the bot account's PAT expires the run will fail at gh repo clone with a 401. Re-run gh auth login.
  • First run after upgrading the Claude Code CLI is the riskiest one. If the new CLI changes its wire format the matrix run can produce systematic failures. Always run with SKIP_PUBLISH=1 after a CLI upgrade before letting the next scheduled fire happen.
  • Disk: the worktree's .venv is ~1.3 GB and the .git directory is ~1 GB. Plan for at least 5 GB free on the VM, otherwise uv sync will fail mid-run and leave you with a half-installed venv.