Authenticates and proxies LLM requests through LiteLLM to enact budgets and per-user management.
Auth strategies supported:
- Firefox Account Auth
- iOS App Attest
- Google Play Integrity
make setupThis creates a virtual environment in .venv/, installs dependencies, and installs the tool locally in editable mode.
MLPA requires Python 3.12 (pinned in .python-version). jwtoxide has no wheels for newer versions and fails to build from source.
-
.envin the repo root with at least:MASTER_KEY=sk-<any-value> # LiteLLM master key MLPA_DEBUG=true
OPENAI_API_KEYandEXA_API_KEYare only needed for those models. -
service_account.jsonin the repo root, with Vertex AI access tofx-gen-ai-sandbox. Either a service account key or your own credentials:gcloud auth application-default login cp ~/.config/gcloud/application_default_credentials.json service_account.jsonCreate it before starting Docker. Otherwise Docker creates an empty directory in its place and LiteLLM fails to start.
./start.shStarts LiteLLM, PostgreSQL and Redis, migrates the app_attest database, creates a virtual LiteLLM key in .env, and runs MLPA on http://localhost:8080 (Swagger at /api/docs).
Warning
start.sh runs docker compose down --volumes first, so each run starts with empty databases. To restart without losing data:
docker compose -f litellm_docker_compose.yaml up -d
.venv/bin/mlpaPrivacy Filter is disabled by default. To run it, build the privacy-filter image and set PRIVACY_FILTER_ENABLED=true in .env; start.sh then enables the privacy-filter Compose profile. When disabled, /health/readiness skips it.
Manual steps
docker compose -f litellm_docker_compose.yaml up -dbash scripts/migrate-app-attest-database-local.sh: creates the database if needed, runs Alembic, and seeds the user capacity row.uv run python scripts/create-and-set-virtual-key.py: writesMLPA_VIRTUAL_KEYto.env..venv/bin/mlpa
make docker-up runs steps 1–3.
Without a real FxA token, sign a local MLPA access token with the default dev secret:
TOKEN=$(uv run python -c "from mlpa.core.utils import issue_mlpa_access_token as t; print(t('local-test-user'))")
curl localhost:8080/v1/chat/completions \
-H "Authorization: Bearer $TOKEN" -H 'use-play-integrity: true' -H 'service-type: ai' \
-H 'Content-Type: application/json' \
-d '{"model":"vertex_ai/mistral-small-2503","messages":[{"role":"user","content":"hi"}]}'Config (see LiteLLM Documentation for more config options)
Service account configured to hit VertexAI: service_account.json should be in directory root
After running, Swagger can be viewed at http://localhost:<PORT>/api/docs
In GitHub Actions, select Test gateway image → Run workflow and supply the full
published litellm-gateway image reference from Artifact Registry. Use a commit
tag or digest to identify the candidate:
gh workflow run test-gateway-image.yml --repo Firefox-AI/MLPA \
-f 'image=us-docker.pkg.dev/moz-fx-llm-proxy-prod-b0b8/llm-proxy-prod/litellm-gateway:<commit-sha>'The workflow pulls the image and runs src/tests/litellm_compatibility with
PostgreSQL, Redis, and the local Exa stub. Normal CI uses the upstream image pinned
in litellm_docker_compose.yaml; the manual workflow overrides only that image.
Both use the same compatibility tests and configuration. The workflow does not
deploy the candidate or enable its custom handlers in the test configuration.
src/tests/bench/ times each auth path with pytest-benchmark. FxA, Google, Apple and Postgres are replaced with in-memory fakes, but the real crypto runs (RS256 FxA tokens, HS256 MLPA access tokens, App Attest ECDSA assertions). The suite covers:
fxa_authwith a stub client (MLPA's own overhead) and with the real pyfxa client, both on a cache miss and on a cache hitfxa_authunder concurrent requests to a client that blocks for 5 ms, which catches FxA calls that block the event loop (this one does not track CPU cost)- dev auth (
auth_with_key) - the MLPA access-token check used after Play Integrity
- App Attest
verify_assertand the fullapp_attest_auth, including the challenge check authorize_chat_requestdispatch for each auth header combination, with the auth backends stubbed out
make bench # run the suite once (a few seconds)
make bench-compare # compare with origin/main the way CI does
make bench-compare ARGS="--base my-branch --runs 3"make test skips the benchmarks. They run only with --benchmark-only.
The bench workflow runs on PRs that touch src/, pyproject.toml, uv.lock, scripts/bench_compare.py or the workflow. scripts/bench_compare.py checks out the base branch in a git worktree and copies the PR's src/tests/bench/ into it. Each side gets its own virtualenv from its own uv.lock, so dependency bumps are measured too. The script then runs the suite BENCH_RUNS times per side, alternating between base and PR.
The job summary has one row per bench. Each value is the time per operation: the median of the per-run medians, divided by the number of operations batched into each round.
| Result | Meaning |
|---|---|
| ✅ | Within the threshold. |
| ❌ | The PR is more than the threshold slower than base. The job fails. |
| The bench failed on the PR side. The job fails. | |
| 🆕 | The bench is new and has no baseline on base. |
CI machines vary by 10–30% from run to run, so a single run per side gives false alarms. Repeating whole runs and comparing medians filters out that noise. If the job fails, rerun it once. A real regression fails again with a similar change.
| Setting | Default | Change it with |
|---|---|---|
| Fail threshold | 20% |
Repository variable BENCH_FAIL_THRESHOLD, or --threshold |
| Runs per side | 5 |
Repository variable BENCH_RUNS, or --runs |
| Rounds per bench | 50 |
ROUNDS in src/tests/bench/harness.py |
Set the repository variables under Settings → Secrets and variables → Actions → Variables. A lower threshold catches smaller regressions but needs more runs to stay quiet on unchanged code. Rerun an unchanged branch several times before lowering it.
Because the workflow uses a paths filter, it does not run on PRs that change only docs. If you make it a required check, allow skipped runs in the branch protection rules.
- Add a
test_bench_*.pyfile insrc/tests/bench/. Keep fakes insrc/tests/bench/fakes.py, not in other test folders, because the base side runs this folder too. - Use
run_asyncorrun_syncfromtests.bench.harness. Batch enough operations per round that a round takes at least about 100 µs. - Assert on the result inside the timed function, so a bench that silently takes a different code path fails instead of reporting a misleading time.
- If the bench targets code that does not exist on
mainyet, load it withrequire("module.path", "name"). On the base side that reports "no baseline" instead of failing.