diff --git a/.gitignore b/.gitignore index 38bf9554b5bb..986e48ed7cff 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,12 @@ .venv_policy_test .env .claude + +# e2e: rendered config produced at proxy-start (contains derived model +# names + env-var references; never edit by hand). +e2e/_config/.litellm.rendered.yaml +e2e/tools/__pycache__/ +e2e/_config/.*.swp .newenv newenv/* litellm/proxy/myenv/* diff --git a/CLAUDE.md b/CLAUDE.md index a2716876b123..d1a062f00462 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,6 +45,38 @@ When contributing to the project, use the appropriate templates: - Add at least 1 test in `tests/litellm/` - Ensure `make test-unit` passes +### Branching strategy (internal fork) + +This fork pins to the upstream `v1.83.10-stable` tag and ships internal +fixes on top of it. + +**Branches:** + +| Branch | Purpose | Stays clean? | +|---|---|---| +| `v1.83.10-stable` (tag) | Immutable upstream pin | yes — never moves | +| `ship/v1.83.10` | Long-term ship branch — starts at the tag, only advances via merges of internal `fix/*` PRs | yes | +| `internal/v1.83.10-stable` | Upstream-sync working branch — may collect upstream commits via teammate / CI sync | **no** — can have hundreds of upstream commits | +| `litellm_internal_staging` | Pure upstream tracker for `BerriAI/litellm` | tracks upstream | +| `fix/` | Per-bug feature branch | yes — merged into `ship/v1.83.10` via PR merge commit | + +**PR target:** every internal fix PR **must target `ship/v1.83.10`**, not +`internal/v1.83.10-stable` (which has 1700+ upstream-sync commits on top +of the tag) and not `litellm_internal_staging` (pure upstream). + +```bash +# Default new fix branch from the latest ship state +git checkout -b fix/ ship/v1.83.10 + +# Open PR +gh pr create --base ship/v1.83.10 --head fix/ +``` + +**Conflicts:** `ship/v1.83.10` only moves when a `fix/*` PR merges, so it +stays exactly TAG + (merged fixes). Fixes never have to rebase against +moving upstream; the upstream-sync churn lives entirely on +`internal/v1.83.10-stable`. + ## Architecture Overview LiteLLM is a unified interface for 100+ LLM providers with two main components: diff --git a/e2e/.env.example b/e2e/.env.example new file mode 100644 index 000000000000..cb301d1e7ca6 --- /dev/null +++ b/e2e/.env.example @@ -0,0 +1,35 @@ +# Copy to e2e/.env and fill in real keys. e2e/.env is gitignored. +# +# Only the keys you have are required; cases for missing providers will +# fail fast with a clear "API key missing" error from `e2e/tools/call`. + +# ─── credentials ────────────────────────────────────────────────────── +ANTHROPIC_API_KEY= +OPENAI_API_KEY= + +# ─── provider base URLs (optional) ──────────────────────────────────── +# Gateway, Azure OpenAI, region-pinned host, self-hosted proxy, etc. +# Leave blank to use each provider's default. +# Examples: +# ANTHROPIC_API_BASE=https://api.anthropic.com +# OPENAI_API_BASE=https://api.openai.com/v1 +# OPENAI_API_BASE=https://your-resource.openai.azure.com/openai/deployments/ +ANTHROPIC_API_BASE= +OPENAI_API_BASE= + +# ─── upstream models (optional) ─────────────────────────────────────── +# The full litellm model identifier including provider prefix (so you can +# route to bedrock/vertex/openrouter without code changes). +# Leave blank to use the defaults shown. +# +# Defaults: +# MODEL_ANTHROPIC_SONNET=anthropic/claude-3-5-sonnet-20241022 +# MODEL_ANTHROPIC_HAIKU=anthropic/claude-3-5-haiku-20241022 +# MODEL_OPENAI=openai/gpt-4o-mini +MODEL_ANTHROPIC_SONNET= +MODEL_ANTHROPIC_HAIKU= +MODEL_OPENAI= + +# ─── misc ───────────────────────────────────────────────────────────── +# Override default proxy port (4011) if you have a conflict. +# E2E_PROXY_PORT=4011 diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 000000000000..e8373e46532b --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,169 @@ +# LiteLLM E2E Test Harness — Claude-driven + +This directory is a **toolkit + runbook library** for end-to-end testing +of the litellm proxy against real provider APIs. + +**Philosophy**: Claude Code drives the test sessions. Scripts are +single-purpose Unix tools; runbooks (`cases/*.md`) describe scenarios. +No pytest, no framework lock-in. Tools also work fine when invoked by +a human. + +``` +e2e/ +├── README.md ← you are here +├── .env.example ← copy to .env, fill in keys +├── _config/ +│ └── docker-compose.yml ← builds litellm from local source + Postgres +├── tools/ +│ ├── proxy ← lifecycle: start | stop | status | logs | rebuild | url +│ ├── call ← issue one chat-completions request, output JSON +│ ├── metrics ← /metrics: snapshot | diff | get +│ ├── keys ← virtual key lifecycle: new | info | delete | hash +│ └── teams ← team lifecycle: new | info | delete +└── cases/ + ├── README.md ← index of test scenarios + └── 01..07_*.md ← runbooks Claude executes +``` + +## One-time setup + +```bash +# 1. Provide API keys (and optionally base URLs / model overrides) +cp e2e/.env.example e2e/.env +$EDITOR e2e/.env +``` + +`e2e/.env` supports the following keys (all optional except API keys for +providers you intend to exercise): + +| Key | Purpose | Default | +|---|---|---| +| `ANTHROPIC_API_KEY` | Anthropic credential | — (required for case 01-04) | +| `OPENAI_API_KEY` | OpenAI credential | — (required for case 05-06) | +| `ANTHROPIC_API_BASE` | Gateway / region-pinned endpoint | `https://api.anthropic.com` | +| `OPENAI_API_BASE` | Gateway / Azure / self-hosted | `https://api.openai.com/v1` | +| `MODEL_ANTHROPIC_SONNET` | Full litellm model id | `anthropic/claude-3-5-sonnet-20241022` | +| `MODEL_ANTHROPIC_HAIKU` | Full litellm model id | `anthropic/claude-3-5-haiku-20241022` | +| `MODEL_OPENAI` | Full litellm model id | `openai/gpt-4o-mini` | +| `E2E_PROXY_PORT` | Host port for the proxy | `4011` | + +Each `MODEL_*` value must include the provider prefix +(`anthropic/`, `openai/`, `bedrock/`, `vertex_ai/`, `openrouter/`, ...). +This lets you point the same `model_name` slot at a non-default routing +path without touching code. + +```bash +# 2. Pre-build the image (subsequent starts are instant) +e2e/tools/proxy rebuild # ~3-5 min first time +``` + +The Python interpreter that runs the tools must have +`prometheus_client` available. Easiest: use the litellm dev venv +(`make install-dev` or `uv run python e2e/tools/metrics ...`). + +The proxy config is **generated** from `.env` at `proxy start` time and +written to `e2e/_config/.litellm.rendered.yaml` (gitignored). Edit `.env` ++ rerun `e2e/tools/proxy restart` to pick up changes — never edit the +rendered file by hand. + +### Postgres (always-on, ephemeral) + +`proxy start` brings up a Postgres 16 container alongside litellm so DB-backed +features (virtual keys, teams, spend logs) work out of the box. The DB is +**ephemeral** — every `proxy stop` (or `restart`) wipes data. This keeps test +runs reproducible and prevents stale virtual keys from poisoning later cases. + +If you need persistence (e.g. to attach a debugger to spend logs), edit +`e2e/_config/docker-compose.yml` and add a `volumes:` block under the `db` +service. + +## Typical session + +```bash +# 1. Boot proxy +e2e/tools/proxy start + +# 2. Sanity smoke test (no API key needed) +# → see e2e/cases/07_prometheus_endpoint_smoke.md + +# 3. Drive a real test (Claude reads the runbook and executes) +# → see e2e/cases/01_prometheus_anthropic_creation_5m.md + +# 4. When done +e2e/tools/proxy stop +``` + +## How Claude uses this + +Tell Claude: + +> "Run case 01 against the running proxy and report what you see." + +Claude will: +1. `cat e2e/cases/01_prometheus_anthropic_creation_5m.md` +2. Execute the Steps via the Bash tool +3. Compare actual against Expected +4. Surface diffs, judge pass/fail, debug if needed + +Because Claude is the orchestrator, it can: +- Adapt mid-test (e.g. retry with a longer prompt if `cache_creation=0`) +- Cross-check provider responses against metric deltas +- Open a logs tail when something looks off +- Decide to skip cases that don't apply to your account + +## Adding new cases + +1. Drop a new markdown file under `cases/` following the existing + Goal / Preconditions / Steps / Expected shape. +2. If the case uses a new metric / endpoint, the existing 3 tools may + already cover it. Only add a new tool when the same logic is needed + in ≥ 2 cases. +3. Update `cases/README.md` index. + +## Tools reference (cheat sheet) + +```bash +# Proxy +e2e/tools/proxy start # boot (idempotent) — brings up db + litellm +e2e/tools/proxy stop # tear down (wipes db) +e2e/tools/proxy status # exit 0 if ready +e2e/tools/proxy logs --tail 100 -f # follow logs +e2e/tools/proxy rebuild # force image rebuild after source change +e2e/tools/proxy url # prints e.g. http://localhost:4011 + +# Make a call (full response JSON on stdout) +e2e/tools/call --provider anthropic --cache ephemeral --ttl 5m +e2e/tools/call --provider anthropic --cache none +e2e/tools/call --provider openai --prompt-tokens 1800 --seed run42 +e2e/tools/call --provider anthropic --api-key sk-... # use virtual key + +# Metrics +e2e/tools/metrics snapshot # → JSON +e2e/tools/metrics get litellm_prompt_cache_read_tokens_metric +e2e/tools/metrics get litellm_prompt_cache_read_tokens_metric \ + --label api_provider=anthropic +e2e/tools/metrics diff before.json after.json \ + --metric litellm_prompt_cache_creation_tokens_metric \ + --label cache_ttl=5m + +# Virtual keys (needs DB) +e2e/tools/keys new --alias my-key --models claude-sonnet-cache --duration 30m +e2e/tools/keys hash sk-... # print sha256 → matches `hashed_api_key` label +e2e/tools/keys delete --key sk-... + +# Teams (needs DB) +e2e/tools/teams new --alias team-foo --max-budget 10 +e2e/tools/teams delete --team-id +``` + +## Cost discipline + +These tests call real provider APIs. Per-case cost is < $0.01 with +default prompt sizes, but adds up if you `loop` recklessly. The case +runbooks are intentionally short — one or two calls each. + +## Out of scope + +- Load / concurrency testing (see `tests/load_tests/`) +- Per-virtual-key isolation (needs Postgres; add when needed) +- CI automation (these cost money; run on-demand only) diff --git a/e2e/_config/docker-compose.yml b/e2e/_config/docker-compose.yml new file mode 100644 index 000000000000..d9e72e0c8ce1 --- /dev/null +++ b/e2e/_config/docker-compose.yml @@ -0,0 +1,84 @@ +# E2E docker-compose. Builds litellm proxy from the local source tree +# (so unreleased fixes on the current branch are exercised), exposes /metrics +# on port 4011 to avoid colliding with a developer's regular proxy on 4000, +# forwards real provider API keys from `e2e/.env`, and runs an ephemeral +# Postgres so virtual-key / team / spend-log features can be exercised. +# +# Postgres is INTENTIONALLY ephemeral — no named volume — so each +# `e2e/tools/proxy restart` wipes accumulated test data. To persist data +# across restarts, add a `volumes:` declaration manually (not recommended +# for test runs: stale virtual keys + spend logs poison later cases). +# +# Run via `e2e/tools/proxy {start|stop|status|logs|rebuild}` — not `docker +# compose` directly — so behavior stays consistent across machines. + +services: + db: + image: postgres:16-alpine + container_name: litellm-e2e-db + environment: + POSTGRES_DB: litellm + POSTGRES_USER: litellm + POSTGRES_PASSWORD: e2e-test-only-not-a-secret + # No `ports:` — Postgres is only reachable inside the compose network. + # No `volumes:` — ephemeral by design. + healthcheck: + test: ["CMD-SHELL", "pg_isready -U litellm -d litellm"] + interval: 3s + timeout: 2s + retries: 10 + start_period: 5s + restart: "no" + + litellm: + build: + context: ../.. # repo root — picks up local source changes + dockerfile: Dockerfile + image: litellm-e2e:local + container_name: litellm-e2e + ports: + - "127.0.0.1:4011:4000" + environment: + LITELLM_MASTER_KEY: "sk-e2e-test" + LITELLM_LOG: "INFO" + # Match the user's production env: force using the locally-bundled + # model_prices JSON instead of fetching the latest from GitHub. + LITELLM_LOCAL_MODEL_COST_MAP: "True" + # DB wiring — litellm runs `prisma migrate deploy` on startup against + # this URL. Compose-network DNS resolves `db` to the postgres container. + DATABASE_URL: "postgresql://litellm:e2e-test-only-not-a-secret@db:5432/litellm" + STORE_MODEL_IN_DB: "True" + # Forwarded from e2e/.env via `env_file` + ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}" + OPENAI_API_KEY: "${OPENAI_API_KEY:-}" + # Optional provider base URLs (gateway / Azure / self-hosted endpoints). + # Blank = use provider default (Anthropic / OpenAI fallback chains + # treat empty string as "use default", so forwarding "" is safe here.) + ANTHROPIC_API_BASE: "${ANTHROPIC_API_BASE:-}" + OPENAI_API_BASE: "${OPENAI_API_BASE:-}" + env_file: + - path: ../.env + required: false + volumes: + # Rendered at proxy-start time by `e2e/tools/proxy` from .env values. + # See ../tools/proxy `render_config()`. The file is gitignored. + - ./.litellm.rendered.yaml:/app/config.yaml:ro + command: + - --config=/app/config.yaml + - --port=4000 + depends_on: + db: + condition: service_healthy + healthcheck: + # /health/liveliness comes for free without DB / provider connectivity, + # but readiness needs migrations to finish — give it more time. + test: + - CMD-SHELL + - >- + python3 -c "import urllib.request; + urllib.request.urlopen('http://localhost:4000/health/liveliness')" + interval: 5s + timeout: 3s + retries: 24 # 24*5s = 2 min; covers prisma migrate cold start + start_period: 30s + restart: "no" diff --git a/e2e/cases/01_prometheus_anthropic_creation_5m.md b/e2e/cases/01_prometheus_anthropic_creation_5m.md new file mode 100644 index 000000000000..b48e7c0ead81 --- /dev/null +++ b/e2e/cases/01_prometheus_anthropic_creation_5m.md @@ -0,0 +1,72 @@ +# Case 01 — Anthropic prompt cache creation (5m TTL) + +## Goal + +A single request that marks the system prompt with +`cache_control: {"type": "ephemeral"}` should: +1. Cause Anthropic to write a 5-minute cache entry, reflected as + `cache_creation_input_tokens > 0` in the API response. +2. Increment + `litellm_prompt_cache_creation_tokens_metric{cache_ttl="5m", api_provider="anthropic"}` + by exactly that amount. +3. **Not** increment any `cache_ttl="1h"` series. +4. **Not** increment `litellm_prompt_cache_read_tokens_metric` (first + request, nothing to read yet). + +## Preconditions + +- `ANTHROPIC_API_KEY` set in `e2e/.env` +- `e2e/tools/proxy status` reports `ready` + +## Steps + +```bash +# 0. Confirm baseline state +e2e/tools/proxy status + +# 1. Snapshot metrics +e2e/tools/metrics snapshot > /tmp/m_before.json + +# 2. Make one Anthropic request with 5m ephemeral cache. +# Seed = unique to avoid reading a cache entry from a previous run. +SEED="case01-$(date +%s)" +e2e/tools/call \ + --provider anthropic \ + --cache ephemeral \ + --ttl 5m \ + --prompt-tokens 1500 \ + --seed "$SEED" \ + > /tmp/call_response.json + +# 3. Snapshot metrics again +e2e/tools/metrics snapshot > /tmp/m_after.json + +# 4. Inspect what the provider actually did +jq '.response.usage' /tmp/call_response.json + +# 5. Diff metrics +e2e/tools/metrics diff /tmp/m_before.json /tmp/m_after.json \ + --metric litellm_prompt_cache_creation_tokens_metric +e2e/tools/metrics diff /tmp/m_before.json /tmp/m_after.json \ + --metric litellm_prompt_cache_read_tokens_metric +``` + +## Expected + +- `response_status` == 200 in `/tmp/call_response.json` +- `.response.usage.cache_creation_input_tokens` > 0 +- `.response.usage.cache_read_input_tokens` == 0 (first time this prefix is sent) +- Diff for `litellm_prompt_cache_creation_tokens_metric` shows **exactly one** + row with `cache_ttl="5m"`, `api_provider="anthropic"`, `delta` == + `cache_creation_input_tokens` +- Diff for `litellm_prompt_cache_read_tokens_metric` shows no rows + (or shows rows only from unrelated traffic — judge by labels) + +## Common failures + +| Symptom | Likely cause | +|---|---| +| `cache_creation_input_tokens=0` | prompt too short — bump `--prompt-tokens` to 2200 (haiku threshold) | +| `response_status=401` | `ANTHROPIC_API_KEY` missing or wrong | +| `response_status=400 "cache_control invalid"` | model doesn't support caching (use Sonnet 3.5+) | +| metric delta = 0 but usage shows tokens | `callbacks: ["prometheus"]` missing from config, or proxy was started before our code fix landed (run `e2e/tools/proxy rebuild`) | diff --git a/e2e/cases/02_prometheus_anthropic_creation_1h.md b/e2e/cases/02_prometheus_anthropic_creation_1h.md new file mode 100644 index 000000000000..2f39616f0099 --- /dev/null +++ b/e2e/cases/02_prometheus_anthropic_creation_1h.md @@ -0,0 +1,50 @@ +# Case 02 — Anthropic prompt cache creation (1h TTL, extended) + +## Goal + +Same as Case 01, but with `ttl: "1h"`. Verify the `cache_ttl="1h"` +bucket of the creation counter increments instead of `5m`. + +## Preconditions + +- `ANTHROPIC_API_KEY` set, **with extended-cache-ttl support enabled** + for the account. Most accounts have this by default in 2026; older + accounts may still need to opt in. If the request 400s with a + beta-header error, this case is N/A on your account. +- `e2e/tools/proxy status` reports `ready` + +## Steps + +```bash +e2e/tools/metrics snapshot > /tmp/m_before.json + +SEED="case02-$(date +%s)" +e2e/tools/call \ + --provider anthropic \ + --cache ephemeral \ + --ttl 1h \ + --prompt-tokens 1500 \ + --seed "$SEED" \ + > /tmp/call_response.json + +e2e/tools/metrics snapshot > /tmp/m_after.json + +jq '.response.usage' /tmp/call_response.json +e2e/tools/metrics diff /tmp/m_before.json /tmp/m_after.json \ + --metric litellm_prompt_cache_creation_tokens_metric +``` + +## Expected + +- `response_status` == 200 +- `.response.usage.cache_creation_input_tokens` > 0 +- `.response.usage.cache_creation.ephemeral_1h_input_tokens` > 0 + (newer Anthropic API shape) +- Diff shows **one row** with `cache_ttl="1h"` and matching delta +- **No row** with `cache_ttl="5m"` for this delta + +## Skip rule + +If response is 400 with content like `extended-cache-ttl-2025-04-11 not +enabled` or `requires beta header`, mark this case **N/A on this +account** rather than failing. diff --git a/e2e/cases/03_prometheus_anthropic_read.md b/e2e/cases/03_prometheus_anthropic_read.md new file mode 100644 index 000000000000..53057f55fa73 --- /dev/null +++ b/e2e/cases/03_prometheus_anthropic_read.md @@ -0,0 +1,61 @@ +# Case 03 — Anthropic prompt cache read + +## Goal + +Two identical Anthropic requests (same `cache_control`-marked system +prompt, same seed). The **second** request should read from cache: +- `cache_read_input_tokens > 0` in the API response +- `litellm_prompt_cache_read_tokens_metric` increments + +The first request still writes the cache; you'll see the creation +counter move too, but we focus the assertions on the read counter delta +between request 1 and request 2. + +## Preconditions + +- `ANTHROPIC_API_KEY` set +- `e2e/tools/proxy status` reports `ready` +- Run within ~5 minutes of starting (5m TTL on the cache entry) + +## Steps + +```bash +SEED="case03-$(date +%s)" + +# 1. First call — writes the cache +e2e/tools/call --provider anthropic --cache ephemeral --ttl 5m \ + --prompt-tokens 1500 --seed "$SEED" > /tmp/call_first.json +jq '.response.usage' /tmp/call_first.json + +# 2. Snapshot AFTER first call so we measure the *read* delta cleanly +e2e/tools/metrics snapshot > /tmp/m_before.json + +# 3. Second call — should hit cache +e2e/tools/call --provider anthropic --cache ephemeral --ttl 5m \ + --prompt-tokens 1500 --seed "$SEED" > /tmp/call_second.json +jq '.response.usage' /tmp/call_second.json + +# 4. Snapshot after +e2e/tools/metrics snapshot > /tmp/m_after.json + +# 5. Diff: focus on the read metric +e2e/tools/metrics diff /tmp/m_before.json /tmp/m_after.json \ + --metric litellm_prompt_cache_read_tokens_metric +``` + +## Expected + +- First request: `cache_creation_input_tokens > 0`, + `cache_read_input_tokens == 0` +- Second request: `cache_read_input_tokens > 0`, + `cache_creation_input_tokens` either 0 or small (refresh) +- Diff for `litellm_prompt_cache_read_tokens_metric` shows one row with + `api_provider="anthropic"` and `delta` == second request's + `cache_read_input_tokens` + +## Failure modes + +| Symptom | Action | +|---|---| +| 2nd call shows `cache_read_input_tokens=0` | wait <60s, retry — Anthropic occasionally drops fresh entries under load. Also confirm the seed in both calls is identical. | +| `delta` mismatches `cache_read_input_tokens` | other traffic hitting the proxy concurrently — pause other calls and rerun | diff --git a/e2e/cases/04_prometheus_no_cache_baseline.md b/e2e/cases/04_prometheus_no_cache_baseline.md new file mode 100644 index 000000000000..f925c86e320e --- /dev/null +++ b/e2e/cases/04_prometheus_no_cache_baseline.md @@ -0,0 +1,39 @@ +# Case 04 — No cache_control means no cache metric emission + +## Goal + +A plain Anthropic request without `cache_control` markers should +**neither** create a cache entry **nor** emit any of our new +provider-prompt-cache metrics. This guards against accidentally +counting every request as a cache event. + +## Preconditions + +- `ANTHROPIC_API_KEY` set +- `e2e/tools/proxy status` reports `ready` + +## Steps + +```bash +e2e/tools/metrics snapshot > /tmp/m_before.json + +SEED="case04-$(date +%s)" +e2e/tools/call --provider anthropic --cache none \ + --prompt-tokens 1500 --seed "$SEED" > /tmp/call_response.json + +e2e/tools/metrics snapshot > /tmp/m_after.json + +jq '.response.usage' /tmp/call_response.json +e2e/tools/metrics diff /tmp/m_before.json /tmp/m_after.json \ + --metric litellm_prompt_cache_read_tokens_metric +e2e/tools/metrics diff /tmp/m_before.json /tmp/m_after.json \ + --metric litellm_prompt_cache_creation_tokens_metric +``` + +## Expected + +- `response_status` == 200 +- `.response.usage.cache_creation_input_tokens` either absent or 0 +- `.response.usage.cache_read_input_tokens` either absent or 0 +- Both `diff` invocations print `(no deltas)` (or only show rows from + unrelated traffic — judge by labels matching this call's model) diff --git a/e2e/cases/05_prometheus_openai_read.md b/e2e/cases/05_prometheus_openai_read.md new file mode 100644 index 000000000000..2f4d4b8c3f62 --- /dev/null +++ b/e2e/cases/05_prometheus_openai_read.md @@ -0,0 +1,59 @@ +# Case 05 — OpenAI auto prompt-cache read + +## Goal + +OpenAI does prompt caching automatically when a prompt is ≥ 1024 tokens +**and the exact prefix has been seen recently**. There is no +`cache_control` marker — caching is implicit. Two identical long +requests should yield `prompt_tokens_details.cached_tokens > 0` on the +second, and that value should appear in +`litellm_prompt_cache_read_tokens_metric{api_provider="openai"}`. + +## Preconditions + +- `OPENAI_API_KEY` set +- `e2e/tools/proxy status` reports `ready` +- Model in config: `gpt-4o-mini-cache` → `openai/gpt-4o-mini` +- Run both calls within ~5 minutes (idle TTL is 5–10 min) + +## Steps + +```bash +SEED="case05-$(date +%s)" + +# 1. Warm-up call — primes OpenAI's prefix cache +e2e/tools/call --provider openai --prompt-tokens 1800 --seed "$SEED" \ + > /tmp/call_first.json +jq '.response.usage.prompt_tokens_details' /tmp/call_first.json + +# 2. Snapshot +e2e/tools/metrics snapshot > /tmp/m_before.json + +# 3. Identical 2nd call — expect cache hit +e2e/tools/call --provider openai --prompt-tokens 1800 --seed "$SEED" \ + > /tmp/call_second.json +jq '.response.usage.prompt_tokens_details' /tmp/call_second.json + +e2e/tools/metrics snapshot > /tmp/m_after.json + +# 4. Diff +e2e/tools/metrics diff /tmp/m_before.json /tmp/m_after.json \ + --metric litellm_prompt_cache_read_tokens_metric \ + --label api_provider=openai +``` + +## Expected + +- 1st call: `.usage.prompt_tokens_details.cached_tokens` = 0 +- 2nd call: `.usage.prompt_tokens_details.cached_tokens` > 0 +- Diff shows a row with `api_provider="openai"` and `delta` matching + the 2nd call's `cached_tokens` + +## Known flakiness + +OpenAI's automatic caching is best-effort: +- If you exceed ~15 req/min on the same prefix it may overflow to + another shard and miss +- gpt-4o-2024-05-13 and chatgpt-4o-latest do **not** support prompt + caching — stay on gpt-4o-mini for this case +- A second call within < 1s sometimes misses; if so, wait 2-3s and retry diff --git a/e2e/cases/06_prometheus_openai_no_creation.md b/e2e/cases/06_prometheus_openai_no_creation.md new file mode 100644 index 000000000000..74abc1a5aa67 --- /dev/null +++ b/e2e/cases/06_prometheus_openai_no_creation.md @@ -0,0 +1,46 @@ +# Case 06 — OpenAI never emits cache_creation_tokens_metric + +## Goal + +`litellm_prompt_cache_creation_tokens_metric` is an Anthropic-only +concept (LiteLLM's `prompt_tokens_details.cache_creation_tokens` is +populated only from Anthropic's `cache_creation_input_tokens`). Even +when OpenAI auto-caches a long prompt, this metric must **not** +increment for an OpenAI call. + +This case prevents a regression where someone "helpfully" maps OpenAI's +cached tokens into the creation counter — which would muddle dashboards +and overstate cache writes. + +## Preconditions + +- `OPENAI_API_KEY` set +- `e2e/tools/proxy status` reports `ready` + +## Steps + +```bash +e2e/tools/metrics snapshot > /tmp/m_before.json + +SEED="case06-$(date +%s)" +# Two calls so a cache read DEFINITELY happens (max chance of metric +# accidentally firing if mapping is wrong). +e2e/tools/call --provider openai --prompt-tokens 1800 --seed "$SEED" \ + > /tmp/call_first.json +e2e/tools/call --provider openai --prompt-tokens 1800 --seed "$SEED" \ + > /tmp/call_second.json + +e2e/tools/metrics snapshot > /tmp/m_after.json + +# Look for ANY row with api_provider=openai in the creation metric +e2e/tools/metrics diff /tmp/m_before.json /tmp/m_after.json \ + --metric litellm_prompt_cache_creation_tokens_metric \ + --label api_provider=openai +``` + +## Expected + +- Both calls return 200 +- Final diff prints **`(no deltas)`** — zero rows for openai under + `cache_creation_tokens_metric` +- (For sanity, the read metric should still increment — see Case 05) diff --git a/e2e/cases/07_prometheus_endpoint_smoke.md b/e2e/cases/07_prometheus_endpoint_smoke.md new file mode 100644 index 000000000000..c642298344e5 --- /dev/null +++ b/e2e/cases/07_prometheus_endpoint_smoke.md @@ -0,0 +1,49 @@ +# Case 07 — `/metrics` endpoint smoke test + +## Goal + +Quick sanity check that runs in <5 seconds and requires **no provider +key**: confirm the proxy exposes `/metrics`, that the response is the +standard Prometheus text format, and that our two new metric **HELP +lines** are registered (proves the new code path is in the running +image — not just the source tree). + +## Preconditions + +- `e2e/tools/proxy status` reports `ready` + +## Steps + +```bash +# 1. Raw HTTP — confirm 200 + content type +curl -sSI "$(e2e/tools/proxy url)/metrics" | head -3 + +# 2. Snapshot — proves the text parses cleanly +e2e/tools/metrics snapshot > /tmp/snap.json +jq 'keys | length' /tmp/snap.json +# (just a number; should be many dozens of metric names) + +# 3. Look for the two new metric names in the HELP lines +curl -s "$(e2e/tools/proxy url)/metrics" | \ + grep -E "^# HELP litellm_prompt_cache_(read|creation)_tokens_metric" +``` + +## Expected + +- Step 1: `HTTP/1.1 200 OK` and a content-type containing `text/plain` +- Step 2: snapshot key count > 30 (proxy emits many metrics; exact + number depends on traffic) +- Step 3: **two** lines printed, one per new metric, each starting with + `# HELP litellm_prompt_cache_read_tokens_metric` and + `# HELP litellm_prompt_cache_creation_tokens_metric` + +## What this proves vs doesn't + +- Proves: image contains our fix; PrometheusLogger is initialized; + metric names made it into the exposition text +- Does NOT prove: counter logic increments correctly — that's Cases 01-06 + +## Failure mode + +If step 3 prints nothing, run `e2e/tools/proxy rebuild`. The container +was probably built from an older source tree. diff --git a/e2e/cases/08_prometheus_virtual_key_labels.md b/e2e/cases/08_prometheus_virtual_key_labels.md new file mode 100644 index 000000000000..42d129a8ffd4 --- /dev/null +++ b/e2e/cases/08_prometheus_virtual_key_labels.md @@ -0,0 +1,76 @@ +# Case 08 — Per-virtual-key Prometheus labels + +## Goal + +When a request is authenticated with a **virtual key** (not master_key), +the resulting Prometheus samples must be labeled with that key's +**hashed_api_key** (sha256) and **api_key_alias**. This proves the +PrometheusLabelFactoryContext picks up `user_api_key_*` metadata from +the auth chain, not just from master_key fallback. + +Pre-DB cases 01-07 always saw `hashed_api_key=''` and +`api_key_alias='None'`. Case 08 verifies the labels track an actual +DB-backed virtual key. + +## Preconditions + +- Postgres + litellm up (`e2e/tools/proxy status` reports `ready`) +- `ANTHROPIC_API_KEY` set in `.env` + +## Steps + +```bash +# 1. Mint a virtual key with a stable alias +ALIAS="case08-key-$(date +%s)" +e2e/tools/keys new --alias "$ALIAS" --models claude-sonnet-cache \ + --duration 30m > /tmp/keys_new.json +VKEY=$(jq -r '.response.key' /tmp/keys_new.json) +echo "minted vkey alias=$ALIAS" + +# 2. Compute its expected hash for label assertion +EXPECTED_HASH=$(e2e/tools/keys hash "$VKEY") + +# 3. Snapshot before +e2e/tools/metrics snapshot > /tmp/m_before_08.json + +# 4. Call through the virtual key (NOT master_key) +SEED="case08-$(date +%s)" +e2e/tools/call --provider anthropic --cache ephemeral --ttl 5m \ + --prompt-tokens 1500 --seed "$SEED" --api-key "$VKEY" \ + > /tmp/call_08.json +jq '.response.usage.prompt_tokens_details' /tmp/call_08.json + +# 5. Snapshot after +e2e/tools/metrics snapshot > /tmp/m_after_08.json + +# 6. Inspect the new sample: it should carry the EXPECTED_HASH and the +# alias we set. Filter by alias to narrow down. +e2e/tools/metrics diff /tmp/m_before_08.json /tmp/m_after_08.json \ + --metric litellm_prompt_cache_creation_tokens_metric \ + --label api_key_alias=$ALIAS + +# 7. (Optional) confirm the hashed_api_key matches +e2e/tools/metrics diff /tmp/m_before_08.json /tmp/m_after_08.json \ + --metric litellm_prompt_cache_creation_tokens_metric \ + --label hashed_api_key=$EXPECTED_HASH + +# 8. Cleanup +e2e/tools/keys delete --key "$VKEY" > /dev/null +``` + +## Expected + +- `keys new` returns `status=200` and `.response.key` starts with `sk-` +- Step 6 diff shows **exactly one row** with the alias label populated +- Step 7 diff shows the **same row** (proves both labels agree on the + same series) +- Both rows show a positive `delta` matching the call's + `cache_creation_tokens` + +## Failure modes + +| Symptom | Cause | +|---|---| +| `keys new` 4xx | Postgres not ready, or schema migration didn't finish — `e2e/tools/proxy logs --tail 50` | +| diff finds 0 rows with alias | `user_api_key_*` metadata isn't flowing into PrometheusLabelFactoryContext; bug in auth → prometheus integration | +| `EXPECTED_HASH` mismatches the metric's `hashed_api_key` | hashing algorithm drift; `e2e/tools/keys hash` uses sha256 — verify it still matches `litellm.proxy.utils.hash_token` | diff --git a/e2e/cases/09_prometheus_per_team_isolation.md b/e2e/cases/09_prometheus_per_team_isolation.md new file mode 100644 index 000000000000..93f146dc5620 --- /dev/null +++ b/e2e/cases/09_prometheus_per_team_isolation.md @@ -0,0 +1,91 @@ +# Case 09 — Per-team Prometheus label isolation + +## Goal + +Two teams + one key per team. Each team makes a request. Verify +Prometheus metric samples are **split by `team` label** — i.e. the +counters increment on distinct series, not aggregated under +`team='None'`. This is the load-bearing assumption for any per-team +billing dashboard. + +## Preconditions + +- Postgres + litellm up (`e2e/tools/proxy status` reports `ready`) +- `ANTHROPIC_API_KEY` set + +## Steps + +```bash +NOW=$(date +%s) +TEAM_A_ALIAS="team-a-$NOW" +TEAM_B_ALIAS="team-b-$NOW" + +# 1. Create two teams +e2e/tools/teams new --alias "$TEAM_A_ALIAS" --models claude-sonnet-cache \ + > /tmp/team_a.json +e2e/tools/teams new --alias "$TEAM_B_ALIAS" --models claude-sonnet-cache \ + > /tmp/team_b.json +TEAM_A_ID=$(jq -r '.response.team_id' /tmp/team_a.json) +TEAM_B_ID=$(jq -r '.response.team_id' /tmp/team_b.json) +echo "team_a=$TEAM_A_ID team_b=$TEAM_B_ID" + +# 2. Mint a key per team +e2e/tools/keys new --alias "key-a-$NOW" --team-id "$TEAM_A_ID" \ + --models claude-sonnet-cache --duration 30m > /tmp/key_a.json +e2e/tools/keys new --alias "key-b-$NOW" --team-id "$TEAM_B_ID" \ + --models claude-sonnet-cache --duration 30m > /tmp/key_b.json +KEY_A=$(jq -r '.response.key' /tmp/key_a.json) +KEY_B=$(jq -r '.response.key' /tmp/key_b.json) + +# 3. Snapshot baseline +e2e/tools/metrics snapshot > /tmp/m_before_09.json + +# 4. Each team makes a cached request — unique seed per team to avoid +# cross-team cache reads polluting the assertion. +e2e/tools/call --provider anthropic --cache ephemeral --ttl 5m \ + --prompt-tokens 1500 --seed "$TEAM_A_ALIAS" --api-key "$KEY_A" \ + > /tmp/call_09a.json +e2e/tools/call --provider anthropic --cache ephemeral --ttl 5m \ + --prompt-tokens 1500 --seed "$TEAM_B_ALIAS" --api-key "$KEY_B" \ + > /tmp/call_09b.json +echo "team_a usage:"; jq '.response.usage.prompt_tokens_details' /tmp/call_09a.json +echo "team_b usage:"; jq '.response.usage.prompt_tokens_details' /tmp/call_09b.json + +# 5. Snapshot after +sleep 1 +e2e/tools/metrics snapshot > /tmp/m_after_09.json + +# 6. Verify TWO distinct series — one per team_alias +echo "--- team_a delta ---" +e2e/tools/metrics diff /tmp/m_before_09.json /tmp/m_after_09.json \ + --metric litellm_prompt_cache_creation_tokens_metric \ + --label team_alias=$TEAM_A_ALIAS +echo "--- team_b delta ---" +e2e/tools/metrics diff /tmp/m_before_09.json /tmp/m_after_09.json \ + --metric litellm_prompt_cache_creation_tokens_metric \ + --label team_alias=$TEAM_B_ALIAS + +# 7. Cleanup +e2e/tools/keys delete --key "$KEY_A" > /dev/null +e2e/tools/keys delete --key "$KEY_B" > /dev/null +e2e/tools/teams delete --team-id "$TEAM_A_ID" > /dev/null +e2e/tools/teams delete --team-id "$TEAM_B_ID" > /dev/null +``` + +## Expected + +- Both `teams new` calls return 200 with valid `team_id`s +- Both `keys new` calls return 200 and the keys are scoped to their teams +- Step 6 produces **two separate diff rows**: + - one with `team_alias=team-a-`, delta = team A's `cache_creation_tokens` + - one with `team_alias=team-b-`, delta = team B's `cache_creation_tokens` +- No row has `team_alias='None'` for these test calls + +## Failure modes + +| Symptom | Cause | +|---|---| +| Both deltas land under `team='None'` | proxy isn't propagating `user_api_key_team_id` into the StandardLoggingPayload metadata | +| `teams new` 4xx | DB migration not complete; check `e2e/tools/proxy logs` | +| `keys new` 403 "team not found" | typo in `--team-id`; double-check `team_id` extraction from JSON | +| Cache read pollution (one team reads the other's cache) | seeds collided — verify `$TEAM_A_ALIAS != $TEAM_B_ALIAS` and both are passed as `--seed` | diff --git a/e2e/cases/10_cost_breakdown_cache_missing.md b/e2e/cases/10_cost_breakdown_cache_missing.md new file mode 100644 index 000000000000..f3a90cc810e3 --- /dev/null +++ b/e2e/cases/10_cost_breakdown_cache_missing.md @@ -0,0 +1,129 @@ +# Case 10 — Cost breakdown must include cache fields for cached prompts + +## Goal + +Regression guard for a real production bug: dashboard cost breakdown for +`claude-haiku-4-5-20251001` showed only `input_cost` + `output_cost`, +silently absorbing the entire cache portion of the bill. Hitting the +admin **"Reload Price Data"** endpoint repaired the breakdown without a +proxy restart — proving the bug lives in `litellm.model_cost` runtime +state, not the calc logic itself. + +User-observed numbers (single Haiku 4.5 call, +`prompt_tokens=100191`, `completion_tokens=151`, +`cache_read=99774`, `cache_creation=416`): + +| Field | Before reload | After reload | +|---|---|---| +| `total_cost` | $0.002689 | $0.011253 | +| `cache_read_cost` in breakdown | (missing) | $0.009977 | +| `cache_creation_cost` in breakdown | (missing) | $0.000520 | + +Delta = $0.008565, exactly `99774*1e-7 + 416*1.25e-6` — the entire cache +charge. + +### Root cause path + +1. `litellm.model_cost["claude-haiku-4-5-20251001"]` lacked + `cache_read_input_token_cost` / `cache_creation_input_token_cost` at + runtime. Candidates: + - Lagging upstream `model_prices_and_context_window.json` fetched at + proxy startup + - `register_model()` overwrote the entry via `_update_dictionary` from + a dynamic source that did not carry cache fields +2. `litellm.get_model_info()` then returns those keys as `None` (or + raises), inside the `try/except` block at + `litellm/cost_calculator.py:1605-1632` +3. The `except Exception: pass` swallows the failure silently — + `_cache_read_cost` and `_cache_creation_cost` stay `None`, so the + breakdown dict stored in `spend_logs.cost_breakdown` omits them +4. Reload endpoint (`/reload_model_cost_map`) re-reads the JSON and + `litellm.add_known_models()` re-merges the missing keys; the next + request renders the full breakdown + +This case asserts every link in that chain is healthy. + +## Preconditions + +- `e2e/tools/proxy status` reports `ready` +- `LITELLM_LOCAL_MODEL_COST_MAP=True` set in docker-compose (already is — + ensures the bundled JSON is the source of truth so the test is + deterministic regardless of upstream lag) + +No `ANTHROPIC_API_KEY` needed: the case calls `litellm.completion_cost()` +directly against a fabricated `Usage` shape. It costs nothing and never +hits a provider. + +## Steps + +```bash +# 1. Ship the regression fixture into the running container +docker cp e2e/cases/data/10_cost_breakdown_cache_missing.py \ + litellm-e2e:/tmp/case10.py + +# 2. Run the assertions +docker exec litellm-e2e python3 /tmp/case10.py +echo "exit=$?" +``` + +### Optional — negative control (proves the test detects the bug) + +Strip the cache rate keys at runtime and confirm the script exits 1: + +```bash +docker exec litellm-e2e python3 -c " +import os; os.environ['LITELLM_LOCAL_MODEL_COST_MAP'] = 'True' +import litellm +mc = litellm.model_cost['claude-haiku-4-5-20251001'] +mc.pop('cache_read_input_token_cost', None) +mc.pop('cache_creation_input_token_cost', None) +from litellm.utils import get_model_info +try: get_model_info.cache_clear() +except Exception: pass +exec(open('/tmp/case10.py').read()) +"; echo "exit=$?" +``` + +Note: the negative control mutates global state inside the container, so +it should be run **last** in a session, or followed by +`e2e/tools/proxy restart` to reset `litellm.model_cost`. + +## Expected + +### Happy path + +- exit=0 +- Output ends with: + ``` + PASS: cache portion = 0.010497 (93.3% of total) + PASS: all paths agree on total = 0.011253 + ``` +- Three independent code paths all agree on the same total: + manual math, `litellm.completion_cost()`, and the proxy's + `response_cost_calculator()` + +### Negative control + +- exit=1 +- First line: `FAIL: model_cost[...].cache_read_input_token_cost is None` +- Confirms the assertion fires the moment the cache keys disappear + +## Failure modes + +| Symptom | Likely cause | +|---|---| +| `FAIL: ...cache_read_input_token_cost is None` on the happy path | The bundled `model_prices_and_context_window.json` regressed — check `litellm/model_prices_and_context_window_backup.json` for the Haiku 4.5 entry | +| `FAIL: completion_cost mismatch` | Calc rounding or branch change in `cost_calculator.py`; rerun manual math from the printed rates | +| `FAIL: response_cost_calculator mismatch` but `completion_cost` ok | Proxy-side wrapping path (e.g. `_calc_with_usage_object`) diverged from the library path; bisect there | +| Script `ModuleNotFoundError: litellm` | Container is not the e2e proxy image — `e2e/tools/proxy rebuild` | +| `FAIL: test geometry weakened` | Someone changed `CACHE_READ`/`CACHE_CREATE` such that cache portion no longer dominates — restore the original Usage shape so the test stays a meaningful regression | + +## When this case fires + +Treat a happy-path failure as **release-blocking**. The user-visible +symptom is silent under-billing — there is no log line, no metric, no +alert. The only feedback signal in production is "the dashboard total +looks too small," which depends on someone noticing. This regression +case is the only automated tripwire we have for that failure mode until +the silent `except Exception: pass` at `cost_calculator.py:1605-1632` is +replaced with an explicit warning. diff --git a/e2e/cases/11_error_information_message_populated.md b/e2e/cases/11_error_information_message_populated.md new file mode 100644 index 000000000000..d71ad858d8b0 --- /dev/null +++ b/e2e/cases/11_error_information_message_populated.md @@ -0,0 +1,139 @@ +# Case 11 — `error_information.error_message` must be populated on failure + +## Goal + +Regression guard for an observability bug: when a request fails (e.g. +401 with an invalid virtual key), the row written to +`LiteLLM_SpendLogs.metadata.error_information.error_message` is the +empty string, even though the proxy's HTTP response body and the +captured traceback both contain the full human-readable message. + +Dashboard "LLM Failure" rows then look like: + +```json +"error_information": { + "error_code": "401", + "error_class": "ProxyException", + "error_message": "", + "llm_provider": "", + "traceback": "<855 chars of stack trace>" +} +``` + +Operations is left clicking individual rows and unpacking traceback +text just to find out whether the failure was auth, budget, rate +limit, or a hook fault. There is no automated alert tripwire for this +class of regression — once `error_message` goes silent, every failure +mode upstream becomes indistinguishable on the dashboard. + +### Origin + +Observed live in this e2e environment: dashboard polling +`GET /key/list` with a stale (post-restart, no longer in DB) session +token. 26 failures landed in `spend_logs` within 10 seconds, all with +`error_code="401"`, `error_class="ProxyException"`, +`error_message=""`, and `traceback` length 855 chars. Triage was only +possible by manually `psql`-ing the metadata JSON and extracting the +last stack frame. + +## Preconditions + +- `e2e/tools/proxy status` reports `ready` +- The e2e Postgres container is reachable as `litellm-e2e-db` + (default for this harness) + +No real provider key needed — the case deliberately uses an invalid +bearer token to force the auth-rejection path. + +## Steps + +```bash +bash e2e/cases/data/11_error_information_message_populated.sh +echo "exit=$?" +``` + +The script: + +1. Generates a fresh sentinel bearer (`sk-case11-$(date +%s%N)`) so its + SHA-256 hash is guaranteed unique to this run +2. POSTs `/v1/chat/completions` with that bearer — expects HTTP 401 +3. Sleeps 2s for the async spend-logger to land +4. Queries the latest `LiteLLM_SpendLogs` row for that key hash +5. Asserts: + - `error_information.error_code == "401"` + - `error_information.error_class == "ProxyException"` + - `error_information.error_message` non-empty AND contains + `"Authentication Error"` or `"Invalid proxy server token"` + - `error_information.traceback` length > 100 chars + +## Expected + +After the fix lands: + +``` +stored error_code: '401' +stored error_class: 'ProxyException' +stored error_message: 'Authentication Error, Invalid proxy server token passed. ...' +stored traceback len: 855 +PASS: error_information populated correctly +exit=0 +``` + +## Current status — GREEN + +On `fix/prometheus-prompt-cache-tokens` after the fix landed in +`litellm/litellm_core_utils/litellm_logging.py` at +`StandardLoggingPayloadSetup.get_error_information`: + +``` +stored error_message: 'Authentication Error, Invalid proxy server token passed. ...' +stored traceback len: 855 +PASS: error_information populated correctly +exit=0 +``` + +### Root cause (resolved) + +`ProxyException` (`litellm/proxy/_types.py:3453`) sets +`self.message = str(message)` but does NOT call +`super().__init__(message)` and does NOT define `__str__`, so +`str(ProxyException(...))` returns the empty string. + +`get_error_information` previously used `error_message = str(original_exception)`, +which silently dropped the human-readable message for every +`ProxyException` that reached the spend-logger. + +### Fix + +`get_error_information` now reads from `.message` attribute first, +falling back to `str(exc)` only when `.message` is absent. The +`.message` attribute is set uniformly by `ProxyException` and every +`litellm.exceptions.*` class, so the change is backward-compatible +with non-litellm exception types via the `str()` fallback. + +### Companion unit test + +`tests/test_litellm/litellm_core_utils/test_litellm_logging.py`: +- `test_get_error_information_prefers_message_attribute_over_str` +- `test_get_error_information_falls_back_to_str_when_no_message_attr` + +## Failure modes + +| Symptom | Cause | +|---|---| +| `FAIL: no spend_logs row found` | Async logger queue is backed up — bump the `sleep 2` to 5; or DB is not the one the proxy is wired to | +| `FAIL: expected HTTP 401, got 200` | The `sk-case11-...` sentinel collided with an existing key (effectively impossible with `%s%N` granularity, but rerun to be sure) | +| `FAIL: error_class != ProxyException` | A different exception class is being caught — likely a regression in auth_checks; the test should still be RED | +| Mixed PASS / FAIL across reruns | Reruns within the same nanosecond would collide; check that the sentinel hash is actually unique by tailing proxy logs | + +## Notes + +This case is **observability-only** — failed auth requests still +return 401 to the client and never reach a provider, so the bug is +not a billing or correctness issue. But the cost in production +triage time is real: a fleet of 401s with empty `error_message` +costs an operator ~10 minutes per incident vs. ~30 seconds when the +message is populated. + +Treat the RED state as informational until the fix ships; do not +revert / pause this case to silence it. diff --git a/e2e/cases/12_custom_pricing_must_honor_cache_tokens.md b/e2e/cases/12_custom_pricing_must_honor_cache_tokens.md new file mode 100644 index 000000000000..368ad034c33e --- /dev/null +++ b/e2e/cases/12_custom_pricing_must_honor_cache_tokens.md @@ -0,0 +1,230 @@ +# Case 12 — Deployment UUID entry in `litellm.model_cost` must not silently strip cache pricing + +## Goal + +Regression guard for the **actual** production bug that triggered the +"cache pricing only correct after clicking Reload Price Data" report. + +### Root cause (verified end-to-end against the running proxy) + +Three pieces interact: + +1. **Router register-on-startup** + (`litellm/router.py:7230-7237`) + + ```python + _model_id = deployment.model_info.id + if _model_id is not None: + _model_info_dict = deployment.model_info.model_dump(exclude_none=True) + for field in CustomPricingLiteLLMParams.model_fields.keys(): + field_value = deployment.litellm_params.get(field) + if field_value is not None: + _model_info_dict[field] = field_value + litellm.register_model(model_cost={_model_id: _model_info_dict}) + ``` + + For each deployment loaded from DB / config, the router writes an + entry into `litellm.model_cost` keyed by the deployment's **UUID**. + The dict is `model_info.model_dump(exclude_none=True)` merged with + any custom-pricing keys in `litellm_params`. When the dashboard + `/model/new` form was used to add the model — that form exposes + only `input_cost_per_token` and `output_cost_per_token` — and if + `deployment.model_info` did not have static-map cache rates merged + in by the time `_create_deployment` ran, **the UUID entry written + into `litellm.model_cost` permanently lacks + `cache_read_input_token_cost` / `cache_creation_input_token_cost`**. + +2. **Cost calc prefers the UUID over the bare model name** + (`litellm/cost_calculator.py:661-672`) + + ```python + if custom_pricing is True: + if router_model_id is not None and router_model_id in litellm.model_cost: + entry = litellm.model_cost[router_model_id] + if entry.get("input_cost_per_token") is not None or ...: + return_model = router_model_id # ← UUID wins + else: + return_model = model + ``` + + Because the deployment has `input_cost_per_token` set, + `custom_pricing` is `True`. Because the router wrote a UUID entry + in step 1, it is found in `model_cost`. The model name handed to + `cost_per_token` is therefore the UUID — and `cost_per_token` + reads the partial UUID entry, finds `cache_*_input_token_cost = None`, + and drops cache tokens from the bill. + +3. **"Reload Price Data" is a coincidental band-aid** + (`litellm/proxy/proxy_server.py:13319`) + + ```python + litellm.model_cost = new_model_cost_map # ← whole-dict replacement + ``` + + The reload endpoint replaces `litellm.model_cost` wholesale with + the freshly-fetched static JSON. This **incidentally evicts every + deployment-UUID entry** the router previously wrote. On the next + request, `_select_model_name_for_cost_calc` finds the UUID no + longer present, falls through to the bare model name, hits the + complete static-map row, and bills correctly. + + Reload is **not** repopulating cache fields. It is clearing the + stale partial entry so the lookup falls through. + +### Why "Reload fixes some requests but not all" + +- **Single-process effect, multi-worker fleet** — the reload endpoint + only updates `litellm.model_cost` in the worker that handled the + HTTP POST. Other workers (and other machines) read a + `force_reload=True` flag in `LiteLLM_Config` and try to reload on + their next 10-second poll. Whichever worker gets there first clears + the flag back to `False` (`proxy_server.py:5192-5213`), so **any + worker that polls later than that loses the broadcast and never + reloads**. +- **Re-registration overwrites the fix** — the periodic DB-sync / + config-reload task calls `_create_deployment` again, re-running + step 1 above. That writes the partial UUID entry back into + `litellm.model_cost` and the bug returns on the affected worker. +- **Load balancing splits the symptom** — chat requests are spread + across all workers, so some calls hit a freshly-reloaded worker + (correct bill) and some hit a stale worker (under-billed). End + result: dashboard shows partial recovery, never full. + +### Numerical impact (verified) + +For the user-reported Usage (`claude-haiku-4-5-20251001`, +`prompt=100191`, `cache_read=99774`, `cache_creation=416`): + +| Path | Total | Notes | +|---|---|---| +| UUID-path with partial entry (worker before reload, or after re-sync) | **$0.000756** | cache portion silently dropped | +| Bare-model-name path (worker right after reload) | **$0.011253** | correct | + +Delta: **$0.010497 missing** per request, about **−93%** of the bill. +Across many cached requests this is significant revenue lost. + +## Preconditions + +- `e2e/tools/proxy status` reports `ready` +- `LITELLM_LOCAL_MODEL_COST_MAP=True` set in docker-compose +- No provider key needed — the case calls `response_cost_calculator` + directly with a fabricated `Usage` and a sentinel deployment UUID + registered via `litellm.register_model` + +## Steps + +```bash +docker cp e2e/cases/data/12_custom_pricing_must_honor_cache_tokens.py \ + litellm-e2e:/tmp/c12.py +docker exec litellm-e2e python3 /tmp/c12.py +echo "exit=$?" +``` + +The fixture: +1. Reads the static `claude-haiku-4-5-20251001` entry as the correct baseline +2. `litellm.register_model({: {input/output rates only, no cache fields}})` + to mimic what `router.py:7237` does for a `/model/new`-added deployment +3. Builds a `Usage` with `cache_read=99774`, `cache_creation=416` +4. Calls `response_cost_calculator(custom_pricing=True, router_model_id=)` + — exactly the proxy's logging-path shape +5. Asserts the result equals the static-map baseline within $0.0001 + +## Expected (after the fix lands) + +``` +expected total (correct cache billing) = $0.011253 +actual total via UUID path = $0.011253 +PASS: UUID-path total agrees with static-map total within $0.0001 +exit=0 +``` + +## Current status — RED + +On `fix/prometheus-prompt-cache-tokens` and v1.83.10: + +``` +expected total (correct cache billing) = $0.011253 +actual total via UUID path = $0.000756 +FAIL: cost calc via UUID path disagrees with static-map total by $-0.010497 (-93.3%) +exit=1 +``` + +## Suggested fix + +`litellm/router.py:7230-7237` — when writing the UUID entry, merge +cache rate fields from the static map for the bare model name when +the deployment's litellm_params doesn't supply them: + +```python +_model_id = deployment.model_info.id +if _model_id is not None: + _model_info_dict = deployment.model_info.model_dump(exclude_none=True) + + # NEW: backfill cache rate fields from the static model_cost map + # for the bare model name. The dashboard /model/new form does not + # surface these, so without this step a UUID-keyed entry will + # silently strip cache pricing. + bare_model_name = deployment.litellm_params.get("model") + if bare_model_name and bare_model_name in litellm.model_cost: + static_entry = litellm.model_cost[bare_model_name] + for cache_field in ( + "cache_read_input_token_cost", + "cache_read_input_token_cost_above_200k_tokens", + "cache_creation_input_token_cost", + "cache_creation_input_token_cost_above_1hr", + "cache_creation_input_token_cost_above_200k_tokens", + ): + if _model_info_dict.get(cache_field) is None: + value = static_entry.get(cache_field) + if value is not None: + _model_info_dict[cache_field] = value + + # existing override loop unchanged — litellm_params still wins + for field in CustomPricingLiteLLMParams.model_fields.keys(): + field_value = deployment.litellm_params.get(field) + if field_value is not None: + _model_info_dict[field] = field_value + + litellm.register_model(model_cost={_model_id: _model_info_dict}) +``` + +Properties of this fix: + +- **Deterministic** — every worker writes the same UUID entry, regardless + of whether reload has been triggered or how the deployment was added +- **Reload-free** — no operator action required; correctness comes from + the registration step itself +- **Preserves user overrides** — `litellm_params` cache rates still + win if explicitly supplied (e.g. by an enterprise customer with + negotiated discount cache pricing) +- **Multi-worker safe** — every worker independently does the merge + using its own local `litellm.model_cost`; no cross-worker + coordination needed + +A second, **separate** fix is also indicated for the reload +broadcast — the `force_reload` boolean flag at +`proxy_server.py:5192-5213` should be replaced by a monotonic +`last_reload_at` timestamp so all workers definitely observe the +reload. Track this as a separate task; it's out of scope for the +case 12 assertion. + +## Failure modes + +| Symptom | Cause | +|---|---| +| Current: `FAIL: ...disagrees by -93.3%` | Expected — the fix has not landed yet | +| `FAIL: register_model didn't write the UUID entry` | `register_model` API changed signatures; update the test setup | +| `FAIL: UUID entry has cache rates already` | Some upstream code is now auto-merging cache rates at register time — the bug may already be fixed; verify with a fresh `/model/new` round-trip and update this case to GREEN, or extend the simulated litellm_params with cache_field=None overrides to keep the test meaningful | +| `FAIL: static {MODEL} entry incomplete` | Bundled JSON regressed for the baseline model — see case 10 | + +## Cross-reference + +- Case 10 — guards the bare model-name path (static map cache fields + present) +- Case 11 — guards observability (`error_information.error_message` + not silenced) +- This case (12) — guards the deployment-UUID path (the one the user + actually triggered) + +All three must be GREEN for cache billing on dashboard-added +deployments to be trustworthy without operator intervention. diff --git a/e2e/cases/README.md b/e2e/cases/README.md new file mode 100644 index 000000000000..06c6280f412a --- /dev/null +++ b/e2e/cases/README.md @@ -0,0 +1,42 @@ +# E2E Case Library + +These runbooks describe one real-provider test scenario each. They are +designed to be **executed by Claude Code**: read the file, follow the +Steps, judge the Expected outcomes, and report. + +Humans can execute them too — every step is a concrete shell command. + +## Index + +| # | File | Provider | Metric verified | Needs DB | +|---|------|----------|-----------------|---| +| 01 | `01_prometheus_anthropic_creation_5m.md` | Anthropic | `litellm_prompt_cache_creation_tokens_metric{cache_ttl="5m"}` | — | +| 02 | `02_prometheus_anthropic_creation_1h.md` | Anthropic | `litellm_prompt_cache_creation_tokens_metric{cache_ttl="1h"}` | — | +| 03 | `03_prometheus_anthropic_read.md` | Anthropic | `litellm_prompt_cache_read_tokens_metric` | — | +| 04 | `04_prometheus_no_cache_baseline.md` | Anthropic | cache metrics unchanged when no `cache_control` | — | +| 05 | `05_prometheus_openai_read.md` | OpenAI | `litellm_prompt_cache_read_tokens_metric` (provider auto-cache) | — | +| 06 | `06_prometheus_openai_no_creation.md` | OpenAI | `creation_tokens_metric` never emits for OpenAI | — | +| 07 | `07_prometheus_endpoint_smoke.md` | (any) | `/metrics` endpoint serves valid Prometheus text format | — | +| 08 | `08_prometheus_virtual_key_labels.md` | Anthropic | per-virtual-key `hashed_api_key` + `api_key_alias` labels | ✓ | +| 09 | `09_prometheus_per_team_isolation.md` | Anthropic | per-team `team` / `team_alias` label split | ✓ | +| 10 | `10_cost_breakdown_cache_missing.md` | (none — direct calc) | `cost_breakdown.cache_read_cost` / `cache_creation_cost` not silently `None` | — | +| 11 | `11_error_information_message_populated.md` | (none — invalid key) | `spend_logs.metadata.error_information.error_message` non-empty on failure | ✓ | +| 12 | `12_custom_pricing_must_honor_cache_tokens.md` | (none — direct calc) | `custom_cost_per_token` short-circuit must include cache pricing (Bug #2 root cause) | — | + +## How to invoke + +Tell Claude: + +> "Run case 01 and report" +> "Run all anthropic cases" +> "Run case 03 but with the haiku model" + +Claude will read the file, execute the steps, surface the diffs, and +judge against Expected. + +## Common preconditions + +- `e2e/.env` exists with the relevant API keys +- `e2e/tools/proxy status` exits 0 (proxy is running on port 4011) +- The Python env running `e2e/tools/metrics` has `prometheus_client` + installed (litellm's own venv satisfies this) diff --git a/e2e/cases/data/10_cost_breakdown_cache_missing.py b/e2e/cases/data/10_cost_breakdown_cache_missing.py new file mode 100644 index 000000000000..c623acbd9b2e --- /dev/null +++ b/e2e/cases/data/10_cost_breakdown_cache_missing.py @@ -0,0 +1,172 @@ +""" +Regression fixture for Case 10 — cost breakdown must include cache fields. + +Reproduces the exact Usage shape from the user-reported bug +(claude-haiku-4-5-20251001, cache_read=99774, cache_creation=416) and +asserts that: + + 1) litellm.completion_cost() returns the mathematically-correct total + 2) get_model_info() returns non-None cache rate keys + 3) response_cost_calculator() (the proxy path) returns the same total + +The bug we are guarding against: when litellm.model_cost[] is +missing cache rate keys at runtime (because the upstream JSON was lagging +at fetch time, or because register_model() overwrote the entry without +cache fields), get_model_info() raises inside the try/except at +litellm/cost_calculator.py:1605-1632 and cache_read_cost / +cache_creation_cost end up as None in the breakdown — silently absorbing +the entire cache portion of the bill. + +Run via Case 10 runbook (docker exec); exit non-zero on regression. +""" +import os +import sys +import traceback + +os.environ.setdefault("LITELLM_LOCAL_MODEL_COST_MAP", "True") + +import litellm +from litellm import Usage, ModelResponse +from litellm.cost_calculator import response_cost_calculator +from litellm.types.utils import PromptTokensDetailsWrapper + +MODEL = "claude-haiku-4-5-20251001" +PROMPT_TOKENS = 100191 +COMPLETION_TOKENS = 151 +CACHE_READ = 99774 +CACHE_CREATE = 416 + + +def fail(msg: str) -> None: + print(f"FAIL: {msg}") + sys.exit(1) + + +# ---- 1. model_cost entry must have cache fields --------------------- +mc = litellm.model_cost.get(MODEL) +if mc is None: + fail(f"{MODEL} missing from litellm.model_cost " + f"(total entries: {len(litellm.model_cost)})") + +required_fields = [ + "input_cost_per_token", + "output_cost_per_token", + "cache_read_input_token_cost", + "cache_creation_input_token_cost", +] +for k in required_fields: + if mc.get(k) is None: + fail(f"model_cost[{MODEL!r}].{k} is None — " + f"this is the exact bug we are guarding against") + +input_rate = mc["input_cost_per_token"] +output_rate = mc["output_cost_per_token"] +cache_read_rate = mc["cache_read_input_token_cost"] +cache_create_rate = mc["cache_creation_input_token_cost"] + +# ---- 2. get_model_info() must surface the same rates ---------------- +try: + mi = litellm.get_model_info(model=MODEL, custom_llm_provider="anthropic") +except Exception as e: + traceback.print_exc() + fail(f"get_model_info raised {type(e).__name__}: {e}") + +for k in ["cache_read_input_token_cost", "cache_creation_input_token_cost"]: + if mi.get(k) is None: + fail(f"get_model_info().{k} is None — " + f"would trigger silent None breakdown") + +# ---- 3. completion_cost() math must match manual computation -------- +usage = Usage( + prompt_tokens=PROMPT_TOKENS, + completion_tokens=COMPLETION_TOKENS, + total_tokens=PROMPT_TOKENS + COMPLETION_TOKENS, + prompt_tokens_details=PromptTokensDetailsWrapper( + cached_tokens=CACHE_READ, + cache_creation_tokens=CACHE_CREATE, + ), +) +usage.cache_read_input_tokens = CACHE_READ +usage.cache_creation_input_tokens = CACHE_CREATE + +resp = ModelResponse( + id="case10-repro", + object="chat.completion", + created=0, + model=MODEL, + choices=[{ + "index": 0, + "message": {"role": "assistant", "content": "ok"}, + "finish_reason": "stop", + }], + usage=usage, +) +resp._hidden_params = { + "custom_llm_provider": "anthropic", + "additional_headers": {}, +} + +actual_total = litellm.completion_cost( + completion_response=resp, + model=MODEL, + custom_llm_provider="anthropic", +) + +non_cache_prompt = PROMPT_TOKENS - CACHE_READ - CACHE_CREATE +expected_input_cost = non_cache_prompt * input_rate +expected_output_cost = COMPLETION_TOKENS * output_rate +expected_cache_read_cost = CACHE_READ * cache_read_rate +expected_cache_create_cost = CACHE_CREATE * cache_create_rate +expected_total = ( + expected_input_cost + + expected_output_cost + + expected_cache_read_cost + + expected_cache_create_cost +) + +# ---- 4. proxy cost_calculator path must agree ----------------------- +proxy_total = response_cost_calculator( + response_object=resp, + model=MODEL, + custom_llm_provider="anthropic", + call_type="completion", + optional_params={}, + cache_hit=None, + base_model=None, + prompt="", +) + +# ---- 5. Report and assert ------------------------------------------- +print(f"model = {MODEL}") +print(f"input_rate = {input_rate}") +print(f"output_rate = {output_rate}") +print(f"cache_read_rate = {cache_read_rate}") +print(f"cache_create_rate = {cache_create_rate}") +print() +print(f"expected input ({non_cache_prompt} * {input_rate}) = {expected_input_cost}") +print(f"expected output ({COMPLETION_TOKENS} * {output_rate}) = {expected_output_cost}") +print(f"expected cache_read({CACHE_READ} * {cache_read_rate}) = {expected_cache_read_cost}") +print(f"expected cache_crt ({CACHE_CREATE} * {cache_create_rate}) = {expected_cache_create_cost}") +print(f"expected TOTAL = {expected_total}") +print() +print(f"litellm.completion_cost() = {actual_total}") +print(f"response_cost_calculator() = {proxy_total}") + +EPS = 1e-9 +if abs(actual_total - expected_total) > EPS: + fail(f"completion_cost mismatch: got {actual_total}, expected {expected_total}") +if abs(proxy_total - expected_total) > EPS: + fail(f"response_cost_calculator mismatch: got {proxy_total}, expected {expected_total}") + +# Sanity: the cache portion alone must dominate input+output, otherwise +# the test wouldn't catch the bug +cache_portion = expected_cache_read_cost + expected_cache_create_cost +non_cache_portion = expected_input_cost + expected_output_cost +if cache_portion <= non_cache_portion: + fail(f"test geometry weakened: cache_portion ({cache_portion}) must " + f"dominate non_cache_portion ({non_cache_portion}) for the " + f"regression to be detectable") + +print() +print(f"PASS: cache portion = {cache_portion:.6f} ({100*cache_portion/expected_total:.1f}% of total)") +print(f"PASS: all paths agree on total = {actual_total:.6f}") diff --git a/e2e/cases/data/11_error_information_message_populated.sh b/e2e/cases/data/11_error_information_message_populated.sh new file mode 100755 index 000000000000..ee20b2228dbf --- /dev/null +++ b/e2e/cases/data/11_error_information_message_populated.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# Regression fixture for Case 11 — error_information.error_message must be populated. +# +# When auth fails (e.g. invalid virtual key), the spend_logs row's +# metadata.error_information.error_message must contain the human-readable +# error string ("Authentication Error, Invalid proxy server token..."), +# not an empty string. Dashboard "LLM Failure" rows are unusable as a +# triage signal without it. +# +# Verifies against the running e2e proxy + Postgres. + +set -eu + +PROXY_URL="${PROXY_URL:-http://localhost:4011}" +DB_CONTAINER="${DB_CONTAINER:-litellm-e2e-db}" +DB_USER="${DB_USER:-litellm}" +DB_NAME="${DB_NAME:-litellm}" + +# Unique sentinel key so we can find exactly this request's spend_logs row +SENTINEL_KEY="sk-case11-$(date +%s%N)" +HASH=$(printf '%s' "$SENTINEL_KEY" | sha256sum | awk '{print $1}') + +# 1. Trigger the 401 +HTTP_CODE=$(curl -sS -o /dev/null -w '%{http_code}' \ + -X POST "$PROXY_URL/v1/chat/completions" \ + -H "Authorization: Bearer $SENTINEL_KEY" \ + -H "Content-Type: application/json" \ + -d '{"model":"x","messages":[{"role":"user","content":"hi"}]}') +if [ "$HTTP_CODE" != "401" ]; then + echo "FAIL: expected HTTP 401, got $HTTP_CODE" + exit 1 +fi + +# 2. Wait for async spend logger to land +sleep 2 + +# 3. Read the row stored for this key hash +ROW=$(docker exec "$DB_CONTAINER" psql -U "$DB_USER" -d "$DB_NAME" -tA -F'|' -c " +SELECT + COALESCE(metadata::jsonb->'error_information'->>'error_code', ''), + COALESCE(metadata::jsonb->'error_information'->>'error_class', ''), + COALESCE(metadata::jsonb->'error_information'->>'error_message', ''), + COALESCE(length(metadata::jsonb->'error_information'->>'traceback')::text, '0') +FROM \"LiteLLM_SpendLogs\" +WHERE api_key = '$HASH' +ORDER BY \"startTime\" DESC +LIMIT 1; +") +if [ -z "$ROW" ]; then + echo "FAIL: no spend_logs row found for hash $HASH" + exit 1 +fi + +IFS='|' read -r CODE CLASS MSG TB_LEN <<< "$ROW" + +echo "stored error_code: '$CODE'" +echo "stored error_class: '$CLASS'" +echo "stored error_message: '$MSG'" +echo "stored traceback len: $TB_LEN" + +FAIL=0 +if [ "$CODE" != "401" ]; then + echo "FAIL: expected error_code=401, got '$CODE'" + FAIL=1 +fi +if [ "$CLASS" != "ProxyException" ]; then + echo "FAIL: expected error_class=ProxyException, got '$CLASS'" + FAIL=1 +fi +if [ -z "$MSG" ]; then + echo "FAIL: error_message is EMPTY — this is the regression we guard against." + echo " Traceback length is $TB_LEN (>0 means the path that should " + echo " populate error_message did run); only the message itself is lost." + FAIL=1 +elif ! echo "$MSG" | grep -qE "Authentication Error|Invalid proxy server token"; then + echo "FAIL: error_message present but missing expected substring." + echo " got: $MSG" + FAIL=1 +fi +if [ "$TB_LEN" -lt 100 ]; then + echo "FAIL: traceback unexpectedly short ($TB_LEN chars) — logging path may be partially broken" + FAIL=1 +fi + +if [ "$FAIL" -eq 0 ]; then + echo "PASS: error_information populated correctly" + exit 0 +else + exit 1 +fi diff --git a/e2e/cases/data/12_custom_pricing_must_honor_cache_tokens.py b/e2e/cases/data/12_custom_pricing_must_honor_cache_tokens.py new file mode 100644 index 000000000000..b49e4e708fe3 --- /dev/null +++ b/e2e/cases/data/12_custom_pricing_must_honor_cache_tokens.py @@ -0,0 +1,179 @@ +""" +Regression fixture for Case 12 — `router.py:register_model` must not +write a deployment-UUID entry that strips cache rates. + +REAL PROD PATH (verified against a running e2e proxy): + + 1. Proxy startup / DB sync: `router.py:7230-7237` registers each + deployment into `litellm.model_cost` under its UUID. The dict it + writes is `deployment.model_info.model_dump(exclude_none=True)` + plus any `CustomPricingLiteLLMParams` keys from + `deployment.litellm_params`. When the dashboard `/model/new` form + was used to add the model, `litellm_params` carries only + `input_cost_per_token` and `output_cost_per_token` — and if + `deployment.model_info` lacks the static-map cache rates at + register time, the UUID entry written into `litellm.model_cost` + is permanently missing cache fields. + + 2. Cost calc time: `cost_calculator._select_model_name_for_cost_calc` + (cost_calculator.py:661-672) sees `custom_pricing=True` and + prefers the UUID entry over the bare model name. It returns the + UUID, and `cost_per_token` then computes against the partial + entry — cache tokens go unbilled. + + 3. Clicking "Reload Price Data" replaces `litellm.model_cost` + wholesale (proxy_server.py:13319), which incidentally evicts the + UUID entry. Next call resolves the bare model name and gets the + full static-map row, so it bills correctly — until the DB-sync + task re-registers the deployment a few minutes later. + +This case asserts: a deployment registered with partial pricing must +NOT under-bill cache tokens. The fix is in `router.py:_create_deployment` +(see suggested patch in the case markdown). + +Run via Case 12 runbook (docker exec). Exit non-zero when the cost +calc under-bills relative to the correct static-map total. +""" +import os +import sys + +os.environ.setdefault("LITELLM_LOCAL_MODEL_COST_MAP", "True") + +import litellm +from litellm import ModelResponse +from litellm.cost_calculator import response_cost_calculator +from litellm.types.utils import PromptTokensDetailsWrapper, Usage + +DEPLOYMENT_UUID = "case12-539b1c62-ac07-47ae-8987-29426984bb55" +MODEL = "claude-haiku-4-5-20251001" +PROVIDER = "anthropic" + +PROMPT_TOKENS = 100191 +COMPLETION_TOKENS = 151 +CACHE_READ = 99774 +CACHE_CREATE = 416 + + +def fail(msg: str) -> None: + print(f"FAIL: {msg}") + sys.exit(1) + + +# --- correct baseline: pure model_cost lookup (no UUID interference) --- +static_entry = litellm.model_cost.get(MODEL, {}) +input_rate = static_entry.get("input_cost_per_token") +output_rate = static_entry.get("output_cost_per_token") +cache_read_rate = static_entry.get("cache_read_input_token_cost") +cache_create_rate = static_entry.get("cache_creation_input_token_cost") +if None in (input_rate, output_rate, cache_read_rate, cache_create_rate): + fail( + f"static {MODEL} entry incomplete — this case relies on the bundled " + "JSON having full pricing for the baseline model. See case 10." + ) + +non_cache_prompt = PROMPT_TOKENS - CACHE_READ - CACHE_CREATE +expected_total = ( + non_cache_prompt * input_rate + + COMPLETION_TOKENS * output_rate + + CACHE_READ * cache_read_rate + + CACHE_CREATE * cache_create_rate +) + +# --- simulate the broken state router.py:7237 produces ----------------- +litellm.register_model({ + DEPLOYMENT_UUID: { + "input_cost_per_token": input_rate, + "output_cost_per_token": output_rate, + "litellm_provider": PROVIDER, + "mode": "chat", + # cache_*_input_token_cost intentionally absent — exactly what the + # dashboard /model/new form produces, and what gets register_model'd + # if deployment.model_info doesn't have the static-map cache fields + # merged in by the time _create_deployment runs. + } +}) + +if DEPLOYMENT_UUID not in litellm.model_cost: + fail("register_model didn't write the UUID entry — broken assumption") +if litellm.model_cost[DEPLOYMENT_UUID].get("cache_read_input_token_cost") is not None: + fail( + "UUID entry has cache rates already — something is auto-merging that " + "this test was meant to detect; revisit the case design" + ) + +# --- build the request shape the proxy passes to cost calc ------------- +usage = Usage( + prompt_tokens=PROMPT_TOKENS, + completion_tokens=COMPLETION_TOKENS, + total_tokens=PROMPT_TOKENS + COMPLETION_TOKENS, + prompt_tokens_details=PromptTokensDetailsWrapper( + cached_tokens=CACHE_READ, + cache_creation_tokens=CACHE_CREATE, + ), +) +usage.cache_read_input_tokens = CACHE_READ +usage.cache_creation_input_tokens = CACHE_CREATE + +resp = ModelResponse( + id="case12-repro", + object="chat.completion", + created=0, + model=MODEL, + choices=[{ + "index": 0, + "message": {"role": "assistant", "content": "ok"}, + "finish_reason": "stop", + }], + usage=usage, +) +resp._hidden_params = { + "custom_llm_provider": PROVIDER, + "model_id": DEPLOYMENT_UUID, +} + +# --- the actual prod-shaped call --------------------------------------- +actual_total = response_cost_calculator( + response_object=resp, + model=MODEL, + custom_llm_provider=PROVIDER, + call_type="completion", + optional_params={}, + cache_hit=None, + base_model=None, + prompt="", + custom_pricing=True, # litellm_params has input_cost_per_token set + router_model_id=DEPLOYMENT_UUID, +) + +print(f"deployment UUID = {DEPLOYMENT_UUID}") +print(f"static {MODEL} entry has cache rates: yes") +print(f"UUID entry has cache rates: no (router writes partial dict)") +print() +print(f"expected total (correct cache billing) = ${expected_total:.6f}") +print(f"actual total via UUID path = ${actual_total!r}") + +if actual_total is None: + fail("response_cost_calculator returned None — separate regression") + +EPS = 1e-4 +if abs(actual_total - expected_total) > EPS: + print() + print(f"FAIL: cost calc via UUID path disagrees with static-map total by " + f"${actual_total - expected_total:+.6f} " + f"({100*(actual_total - expected_total)/expected_total:+.1f}%)") + print() + print("Cause: router.py:7237 registers the deployment under its UUID " + "with partial pricing. cost_calculator._select_model_name_for_cost_calc " + "prefers the UUID over the bare model name, and cost_per_token then " + "reads the partial entry — cache_*_input_token_cost are None, so " + "the cache portion is dropped.") + print() + print("Fix: in router._create_deployment, when writing the UUID entry " + "into litellm.model_cost, merge the static map's cache rate fields " + "for the bare model name when not provided in litellm_params. See " + "the case markdown 'Suggested fix' section.") + sys.exit(1) + +print() +print(f"PASS: UUID-path total agrees with static-map total within ${EPS}") +sys.exit(0) diff --git a/e2e/tools/call b/e2e/tools/call new file mode 100755 index 000000000000..9529e0f1dead --- /dev/null +++ b/e2e/tools/call @@ -0,0 +1,252 @@ +#!/usr/bin/env python3 +""" +e2e/tools/call — issue one chat/completions request through the E2E proxy +and print the full response JSON to stdout. + +Design +------ +Stdlib only (urllib + json), so it runs on any Python 3.10+ without +extra deps. The proxy itself handles all provider-specific wire details; +this tool just builds the OpenAI-shape payload, optionally adds an +Anthropic `cache_control` marker, sets the master key, and prints what +comes back. + +Examples +-------- + # Anthropic + 5m cache write (default TTL): + e2e/tools/call --provider anthropic --cache ephemeral + + # Anthropic + 1h cache write: + e2e/tools/call --provider anthropic --cache ephemeral --ttl 1h + + # OpenAI: long prompt, auto cache (run twice to see hit): + e2e/tools/call --provider openai --prompt-tokens 2000 --seed run42 + + # Plain call, no caching: + e2e/tools/call --provider anthropic --cache none + +Exit codes +---------- + 0 2xx from proxy + 2 non-2xx response (response body still printed to stdout for diagnosis) + 3 network / connection error + 4 misuse (bad args) +""" +from __future__ import annotations + +import argparse +import json +import os +import sys +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any, Dict, List, Optional + +DEFAULT_PORT = int(os.environ.get("E2E_PROXY_PORT", "4011")) +DEFAULT_HOST = os.environ.get("E2E_PROXY_HOST", "localhost") +DEFAULT_MASTER_KEY = os.environ.get("LITELLM_MASTER_KEY", "sk-e2e-test") + +# Approx 4 chars per token for English (good enough to clear the 1024 / +# 2048 minimum-cacheable-tokens thresholds). +CHARS_PER_TOKEN = 4 + +PROVIDER_MODEL_DEFAULTS = { + "anthropic": "claude-sonnet-cache", + "anthropic-haiku": "claude-haiku-cache", + "openai": "gpt-4o-mini-cache", +} + + +def build_long_text(min_tokens: int, seed: Optional[str]) -> str: + """Generate >= min_tokens of deterministic-looking filler. + + `seed` is mixed into the content. Same seed -> identical prefix + across runs (needed to trigger OpenAI auto-cache or Anthropic + cache read on the second request). + """ + seed_str = seed or "default" + base = ( + f"You are a careful assistant. Session seed: {seed_str}. " + "Below is reference context you should keep in mind. " + ) + para = ( + "Lorem ipsum dolor sit amet, consectetur adipiscing elit. " + "Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. " + "Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris " + "nisi ut aliquip ex ea commodo consequat. " + ) + chars_needed = min_tokens * CHARS_PER_TOKEN + out = [base] + while sum(len(s) for s in out) < chars_needed: + out.append(para) + return "".join(out) + + +def build_messages( + provider: str, + long_text: str, + cache: str, + ttl: str, + user_msg: str, +) -> List[Dict[str, Any]]: + """Anthropic structured `content` array with cache_control vs OpenAI plain.""" + if provider.startswith("anthropic") and cache == "ephemeral": + cache_control: Dict[str, Any] = {"type": "ephemeral"} + if ttl == "1h": + cache_control["ttl"] = "1h" + system_content = [ + { + "type": "text", + "text": long_text, + "cache_control": cache_control, + } + ] + return [ + {"role": "system", "content": system_content}, + {"role": "user", "content": user_msg}, + ] + + # OpenAI: plain text system. Caching is automatic when prompt is long + # enough and a recent identical prefix is observed. + return [ + {"role": "system", "content": long_text}, + {"role": "user", "content": user_msg}, + ] + + +def post_chat_completion( + base_url: str, master_key: str, payload: Dict[str, Any], timeout: float +) -> Dict[str, Any]: + req = urllib.request.Request( + f"{base_url}/v1/chat/completions", + method="POST", + data=json.dumps(payload).encode("utf-8"), + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {master_key}", + }, + ) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + body = resp.read().decode("utf-8") + return {"status": resp.status, "body": body} + except urllib.error.HTTPError as e: + return {"status": e.code, "body": e.read().decode("utf-8", errors="replace")} + except (urllib.error.URLError, TimeoutError, OSError) as e: + return {"status": 0, "body": json.dumps({"connection_error": str(e)})} + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser(prog="e2e/tools/call") + p.add_argument( + "--provider", + choices=["anthropic", "anthropic-haiku", "openai"], + required=True, + ) + p.add_argument( + "--model", + help="Override model_name from config. Defaults per provider.", + ) + p.add_argument( + "--cache", + choices=["ephemeral", "none"], + default="ephemeral", + help="ephemeral=add cache_control marker (anthropic only). " + "none=plain request. (default: ephemeral)", + ) + p.add_argument( + "--ttl", + choices=["5m", "1h"], + default="5m", + help="Anthropic cache TTL. (default: 5m)", + ) + p.add_argument( + "--prompt-tokens", + type=int, + default=1500, + help="Approximate target size of the system prompt. Must clear " + "the provider's cache threshold (Sonnet 1024, Haiku 2048, " + "GPT-4o-mini 1024). (default: 1500)", + ) + p.add_argument( + "--seed", + default=None, + help="Tag to keep prompt identical across runs (so OpenAI auto-cache " + "and Anthropic read-on-second-request both fire reliably).", + ) + p.add_argument( + "--user-message", + default="Reply with a single short word.", + help="The user turn appended after the cached system prompt.", + ) + p.add_argument("--max-tokens", type=int, default=32) + p.add_argument( + "--proxy-url", + default=f"http://{DEFAULT_HOST}:{DEFAULT_PORT}", + help=f"(default: env E2E_PROXY_{{HOST,PORT}} = http://{DEFAULT_HOST}:{DEFAULT_PORT})", + ) + p.add_argument( + "--master-key", + default=DEFAULT_MASTER_KEY, + help="Used unless --api-key is set. Defaults to LITELLM_MASTER_KEY env " + "or sk-e2e-test.", + ) + p.add_argument( + "--api-key", + default=None, + help="Override --master-key for this call (use for virtual-key tests).", + ) + p.add_argument("--timeout", type=float, default=60.0) + return p + + +def main() -> int: + args = build_parser().parse_args() + + model = args.model or PROVIDER_MODEL_DEFAULTS[args.provider] + long_text = build_long_text(args.prompt_tokens, args.seed) + messages = build_messages( + args.provider, long_text, args.cache, args.ttl, args.user_message + ) + payload: Dict[str, Any] = { + "model": model, + "messages": messages, + "max_tokens": args.max_tokens, + } + + auth_key = args.api_key or args.master_key + result = post_chat_completion(args.proxy_url, auth_key, payload, args.timeout) + + # Always print body as JSON. If body itself is valid JSON, re-emit + # pretty; otherwise wrap as { "raw": ... } so stdout is always JSON. + body = result["body"] + try: + parsed = json.loads(body) + except json.JSONDecodeError: + parsed = {"raw": body} + + out = { + "request": { + "proxy_url": args.proxy_url, + "model": model, + "provider": args.provider, + "cache": args.cache, + "ttl": args.ttl if args.cache == "ephemeral" else None, + "prompt_tokens_requested": args.prompt_tokens, + "seed": args.seed, + }, + "response_status": result["status"], + "response": parsed, + } + print(json.dumps(out, indent=2, ensure_ascii=False)) + + if result["status"] == 0: + return 3 + if not (200 <= result["status"] < 300): + return 2 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/e2e/tools/keys b/e2e/tools/keys new file mode 100755 index 000000000000..73e750d8b739 --- /dev/null +++ b/e2e/tools/keys @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +""" +e2e/tools/keys — virtual-key lifecycle wrapper for the E2E proxy. + +Subcommands +----------- + new POST /key/generate. Echoes full response JSON including the + new key. Use --models / --alias / --team-id / --max-budget / + --duration etc. + info GET /key/info?key=. Returns metadata for an existing key. + delete POST /key/delete with `{keys: []}`. Idempotent. + hash Print the SHA-256 hash of a key (matches `hashed_api_key` + label seen in /metrics). No HTTP call. + +Why a separate tool: cases that use virtual keys need to a) mint a key, +b) issue a request through it, c) inspect metrics labeled by its hash, +d) clean up. Keeping this logic in one tool means cases stay short. + +Examples +-------- + e2e/tools/keys new --alias smoke-key-1 --models claude-sonnet-cache + e2e/tools/keys new --alias team-a-key --team-id team-a --models gpt-4o-mini-cache + e2e/tools/keys delete --key sk-... + e2e/tools/keys hash sk-... + +Exit codes mirror tools/call: + 0 success, 2 non-2xx, 3 connection error, 4 misuse +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import sys +import urllib.error +import urllib.request +from typing import Any, Dict, List, Optional + +DEFAULT_PORT = int(os.environ.get("E2E_PROXY_PORT", "4011")) +DEFAULT_HOST = os.environ.get("E2E_PROXY_HOST", "localhost") +DEFAULT_MASTER_KEY = os.environ.get("LITELLM_MASTER_KEY", "sk-e2e-test") + + +def _http( + method: str, + url: str, + master_key: str, + payload: Optional[Dict[str, Any]] = None, + timeout: float = 30.0, +) -> Dict[str, Any]: + body = json.dumps(payload).encode("utf-8") if payload is not None else None + req = urllib.request.Request( + url, + method=method, + data=body, + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {master_key}", + }, + ) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + return {"status": resp.status, "body": resp.read().decode("utf-8")} + except urllib.error.HTTPError as e: + return {"status": e.code, "body": e.read().decode("utf-8", errors="replace")} + except (urllib.error.URLError, TimeoutError, OSError) as e: + return {"status": 0, "body": json.dumps({"connection_error": str(e)})} + + +def _emit(result: Dict[str, Any], extra: Optional[Dict[str, Any]] = None) -> int: + body = result["body"] + try: + parsed = json.loads(body) + except json.JSONDecodeError: + parsed = {"raw": body} + out: Dict[str, Any] = {"status": result["status"], "response": parsed} + if extra: + out.update(extra) + print(json.dumps(out, indent=2, ensure_ascii=False)) + if result["status"] == 0: + return 3 + if not (200 <= result["status"] < 300): + return 2 + return 0 + + +def cmd_new(args: argparse.Namespace) -> int: + payload: Dict[str, Any] = {} + if args.alias: + payload["key_alias"] = args.alias + if args.team_id: + payload["team_id"] = args.team_id + if args.models: + payload["models"] = args.models + if args.max_budget is not None: + payload["max_budget"] = args.max_budget + if args.duration: + payload["duration"] = args.duration + if args.user_id: + payload["user_id"] = args.user_id + if args.metadata: + payload["metadata"] = json.loads(args.metadata) + result = _http("POST", f"{args.proxy_url}/key/generate", args.master_key, payload) + return _emit(result) + + +def cmd_info(args: argparse.Namespace) -> int: + url = f"{args.proxy_url}/key/info?key={args.key}" + result = _http("GET", url, args.master_key) + return _emit(result) + + +def cmd_delete(args: argparse.Namespace) -> int: + payload = {"keys": [args.key]} + result = _http("POST", f"{args.proxy_url}/key/delete", args.master_key, payload) + return _emit(result) + + +def cmd_hash(args: argparse.Namespace) -> int: + """Hash a key the same way litellm does for the `hashed_api_key` + Prometheus label. See `litellm.proxy.utils.hash_token`.""" + digest = hashlib.sha256(args.key.encode("utf-8")).hexdigest() + print(digest) + return 0 + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser(prog="e2e/tools/keys") + p.add_argument("--proxy-url", default=f"http://{DEFAULT_HOST}:{DEFAULT_PORT}") + p.add_argument("--master-key", default=DEFAULT_MASTER_KEY) + sub = p.add_subparsers(dest="cmd", required=True) + + p_new = sub.add_parser("new", help="POST /key/generate") + p_new.add_argument("--alias") + p_new.add_argument("--team-id") + p_new.add_argument( + "--models", + nargs="*", + help="Allowed model_name list (space-separated). Omit = all.", + ) + p_new.add_argument("--max-budget", type=float) + p_new.add_argument( + "--duration", + help="TTL string (e.g. 30d, 1h, 2m). Useful so ephemeral test keys auto-expire.", + ) + p_new.add_argument("--user-id") + p_new.add_argument("--metadata", help="JSON string merged into key metadata") + p_new.set_defaults(func=cmd_new) + + p_info = sub.add_parser("info", help="GET /key/info") + p_info.add_argument("--key", required=True) + p_info.set_defaults(func=cmd_info) + + p_del = sub.add_parser("delete", help="POST /key/delete (idempotent)") + p_del.add_argument("--key", required=True) + p_del.set_defaults(func=cmd_delete) + + p_hash = sub.add_parser( + "hash", help="print sha256(key) — matches `hashed_api_key` label" + ) + p_hash.add_argument("key") + p_hash.set_defaults(func=cmd_hash) + + return p + + +def main() -> int: + args = build_parser().parse_args() + return args.func(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/e2e/tools/metrics b/e2e/tools/metrics new file mode 100755 index 000000000000..2263a4bcb78b --- /dev/null +++ b/e2e/tools/metrics @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +""" +e2e/tools/metrics — inspect the proxy's /metrics endpoint. + +Subcommands +----------- + snapshot GET /metrics, parse, emit normalized JSON to stdout. + Counter samples carry their float value; histograms + carry per-bucket plus sum/count. + diff BEFORE AFTER Compare two snapshot files. By default lists every + metric whose any-label sample changed. Filter with + --metric / --label. + get NAME Print samples matching NAME (+ optional --label + filters). Useful for "show me one number right now". + +Design notes +------------ +- Stdlib + the project's already-installed `prometheus_client` (parser + module). No extra dep. +- JSON shape is intentionally simple so `jq` works downstream: + + { + "scraped_at": "2026-05-14T11:38:00Z", + "proxy_url": "http://localhost:4011", + "metrics": { + "litellm_prompt_cache_read_tokens_metric": [ + { "labels": {"model": "...", ...}, "value": 4096.0 } + ], + ... + } + } + +- For counters, prometheus exposes a `_total`-suffixed sample plus a + `_created` timestamp. We keep only the `_total` (renamed back to the + metric family name) since the timestamp is noise for diff purposes. +""" +from __future__ import annotations + +import argparse +import datetime as _dt +import json +import os +import sys +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +try: + from prometheus_client.parser import text_string_to_metric_families +except ImportError as e: # pragma: no cover + print( + "ERROR: prometheus_client is not installed. " + "Install it: pip install prometheus_client", + file=sys.stderr, + ) + raise SystemExit(4) from e + +DEFAULT_PORT = int(os.environ.get("E2E_PROXY_PORT", "4011")) +DEFAULT_HOST = os.environ.get("E2E_PROXY_HOST", "localhost") + + +# --------------------------------------------------------------------------- +# fetch + parse +# --------------------------------------------------------------------------- + + +def fetch_text(base_url: str, timeout: float = 5.0) -> str: + with urllib.request.urlopen(f"{base_url}/metrics", timeout=timeout) as r: + return r.read().decode("utf-8") + + +def parse_metrics(text: str) -> Dict[str, List[Dict[str, Any]]]: + """Return {metric_family_name: [{"labels": {...}, "value": float}, ...]}. + + Counter "_total" suffix is normalized back to the family name. Each + bucket of a histogram is treated as its own sample with le="...". + """ + out: Dict[str, List[Dict[str, Any]]] = {} + for family in text_string_to_metric_families(text): + family_name = family.name # already trimmed of _total / _bucket + for sample in family.samples: + sample_name = sample.name + # Skip `_created` timestamps — they confuse diffs. + if sample_name.endswith("_created"): + continue + # For histograms keep buckets/sum/count under their suffixed names; + # for everything else, collapse `*_total` to the family name. + if sample_name == family_name + "_total": + key = family_name + else: + key = sample_name + out.setdefault(key, []).append( + {"labels": dict(sample.labels), "value": float(sample.value)} + ) + return out + + +def snapshot(base_url: str) -> Dict[str, Any]: + text = fetch_text(base_url) + return { + "scraped_at": _dt.datetime.now(_dt.timezone.utc).isoformat(), + "proxy_url": base_url, + "metrics": parse_metrics(text), + } + + +# --------------------------------------------------------------------------- +# match + diff +# --------------------------------------------------------------------------- + + +def _labels_match(sample_labels: Dict[str, str], wanted: Dict[str, str]) -> bool: + return all(sample_labels.get(k) == v for k, v in wanted.items()) + + +def find_samples( + snap: Dict[str, Any], metric: str, wanted_labels: Dict[str, str] +) -> List[Dict[str, Any]]: + return [ + s for s in snap["metrics"].get(metric, []) if _labels_match(s["labels"], wanted_labels) + ] + + +def diff_samples( + before: Dict[str, Any], + after: Dict[str, Any], + metric_filter: Optional[str], + label_filter: Dict[str, str], +) -> List[Dict[str, Any]]: + """Return delta records that changed and match filters.""" + results: List[Dict[str, Any]] = [] + metric_names = set(after["metrics"].keys()) | set(before["metrics"].keys()) + for name in sorted(metric_names): + if metric_filter and name != metric_filter: + continue + + # Index samples by frozen label tuple for stable lookup. + def index(snap: Dict[str, Any]) -> Dict[Tuple[Tuple[str, str], ...], float]: + return { + tuple(sorted(s["labels"].items())): s["value"] + for s in snap["metrics"].get(name, []) + } + + b_idx = index(before) + a_idx = index(after) + keys = set(a_idx) | set(b_idx) + for k in keys: + labels = dict(k) + if label_filter and not _labels_match(labels, label_filter): + continue + delta = a_idx.get(k, 0.0) - b_idx.get(k, 0.0) + if delta == 0.0: + # Still surface "appeared from absent" — but that's a + # delta != 0 case, already handled. Skip pure no-change. + continue + results.append( + { + "metric": name, + "labels": labels, + "before": b_idx.get(k, 0.0), + "after": a_idx.get(k, 0.0), + "delta": delta, + } + ) + return results + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +def _parse_label_args(items: List[str]) -> Dict[str, str]: + out: Dict[str, str] = {} + for item in items or []: + if "=" not in item: + raise SystemExit(f"--label expects key=value, got: {item!r}") + k, v = item.split("=", 1) + out[k] = v + return out + + +def cmd_snapshot(args: argparse.Namespace) -> int: + snap = snapshot(args.proxy_url) + json.dump(snap, sys.stdout, indent=2, ensure_ascii=False) + sys.stdout.write("\n") + return 0 + + +def cmd_diff(args: argparse.Namespace) -> int: + before = json.loads(Path(args.before).read_text()) + after = json.loads(Path(args.after).read_text()) + labels = _parse_label_args(args.label) + diffs = diff_samples(before, after, args.metric, labels) + if args.format == "json": + json.dump(diffs, sys.stdout, indent=2, ensure_ascii=False) + sys.stdout.write("\n") + else: # text + if not diffs: + print("(no deltas)") + return 0 + for d in diffs: + label_str = " ".join(f"{k}={v!r}" for k, v in sorted(d["labels"].items())) + print(f"{d['metric']:60s} {d['delta']:+.1f} ({label_str})") + return 0 + + +def cmd_get(args: argparse.Namespace) -> int: + snap = snapshot(args.proxy_url) + samples = find_samples(snap, args.metric, _parse_label_args(args.label)) + if args.format == "json": + json.dump(samples, sys.stdout, indent=2, ensure_ascii=False) + sys.stdout.write("\n") + else: + if not samples: + print("(no matching samples)", file=sys.stderr) + return 1 + for s in samples: + label_str = " ".join(f"{k}={v!r}" for k, v in sorted(s["labels"].items())) + print(f"{s['value']:>16.1f} {label_str}") + return 0 + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser(prog="e2e/tools/metrics") + p.add_argument( + "--proxy-url", default=f"http://{DEFAULT_HOST}:{DEFAULT_PORT}" + ) + sub = p.add_subparsers(dest="cmd", required=True) + + sub.add_parser("snapshot", help="GET /metrics → JSON on stdout").set_defaults( + func=cmd_snapshot + ) + + p_diff = sub.add_parser("diff", help="compare two snapshot files") + p_diff.add_argument("before") + p_diff.add_argument("after") + p_diff.add_argument("--metric", help="restrict to one metric family") + p_diff.add_argument( + "--label", action="append", default=[], help="key=value (repeatable)" + ) + p_diff.add_argument("--format", choices=["text", "json"], default="text") + p_diff.set_defaults(func=cmd_diff) + + p_get = sub.add_parser("get", help="show samples for one metric") + p_get.add_argument("metric") + p_get.add_argument( + "--label", action="append", default=[], help="key=value (repeatable)" + ) + p_get.add_argument("--format", choices=["text", "json"], default="text") + p_get.set_defaults(func=cmd_get) + + return p + + +def main() -> int: + args = build_parser().parse_args() + return args.func(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/e2e/tools/proxy b/e2e/tools/proxy new file mode 100755 index 000000000000..4da96bef89d3 --- /dev/null +++ b/e2e/tools/proxy @@ -0,0 +1,323 @@ +#!/usr/bin/env python3 +""" +e2e/tools/proxy — docker-compose lifecycle wrapper for the litellm test proxy. + +Subcommands +----------- + start Build (if needed) + bring container up in background. + stop Bring container down, remove network. + status Poll /health/readiness; exit 0 if proxy is responding, 1 if not. + logs Tail container logs (Ctrl-C to detach). + rebuild Force rebuild from current source (no cache), useful after a + litellm/ code change. + url Print proxy base URL (e.g. http://localhost:4011) — handy for + piping into other tools. + +All output is plain text on stdout for easy scripting. + +Compose project name is fixed to `litellm-e2e` so multiple worktrees do +not stomp each other; switch workspaces with `--project-name` if needed. +""" +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +import time +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any, Dict, Optional + +THIS_DIR = Path(__file__).resolve().parent +COMPOSE_FILE = (THIS_DIR.parent / "_config" / "docker-compose.yml").resolve() +ENV_FILE = (THIS_DIR.parent / ".env").resolve() +RENDERED_CONFIG = (THIS_DIR.parent / "_config" / ".litellm.rendered.yaml").resolve() +DEFAULT_PORT = int(os.environ.get("E2E_PROXY_PORT", "4011")) +DEFAULT_HOST = os.environ.get("E2E_PROXY_HOST", "localhost") +PROJECT_NAME = "litellm-e2e" + +# Defaults if the corresponding env var is unset/blank in .env. +# Include the provider prefix so users can route to bedrock / vertex / openrouter +# by just changing the env var (e.g. MODEL_ANTHROPIC_SONNET=bedrock/anthropic.claude-...). +DEFAULT_MODEL_ANTHROPIC_SONNET = "anthropic/claude-3-5-sonnet-20241022" +DEFAULT_MODEL_ANTHROPIC_HAIKU = "anthropic/claude-3-5-haiku-20241022" +DEFAULT_MODEL_OPENAI = "openai/gpt-4o-mini" + + +def _load_dotenv() -> None: + """Minimal .env parser — avoids the python-dotenv dependency on the host. + + Honors `KEY=VALUE` and `KEY="..."` lines, ignores comments and blanks, + does NOT override values already in os.environ (shell wins, matching + python-dotenv default behavior). + """ + if not ENV_FILE.exists(): + return + for raw in ENV_FILE.read_text().splitlines(): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, _, v = line.partition("=") + k = k.strip() + v = v.strip().strip('"').strip("'") + if k and k not in os.environ: + os.environ[k] = v + + +def _env_or(name: str, default: str) -> str: + val = os.environ.get(name, "").strip() + return val if val else default + + +def _ensure_provider_prefix(model: str, implied_provider: str) -> str: + """If `model` already contains '/', use as-is (user explicitly chose a + provider — e.g. bedrock/..., vertex_ai/..., openrouter/...). Otherwise + prepend the implied provider prefix. + + This lets a user write `MODEL_OPENAI=glm-5.1` (with an OpenAI-compatible + `OPENAI_API_BASE=https://your-gateway/...`) and get the correct + `openai/glm-5.1` model id automatically. + """ + if "/" in model: + return model + return f"{implied_provider}/{model}" + + +def render_config() -> Path: + """Build the litellm config YAML from env-var overrides. + + Why render instead of using `os.environ/X` for everything? + - `model:` has no fallback chain — empty string would fail at runtime. + - We want to *omit* `api_base` entirely when blank, since some provider + handlers don't gracefully handle empty-string base URLs. + - PyYAML on the host is overkill for ~30 lines — we serialize by hand + so this script stays stdlib-only. + """ + sonnet_model = _ensure_provider_prefix( + _env_or("MODEL_ANTHROPIC_SONNET", DEFAULT_MODEL_ANTHROPIC_SONNET), + implied_provider="anthropic", + ) + haiku_model = _ensure_provider_prefix( + _env_or("MODEL_ANTHROPIC_HAIKU", DEFAULT_MODEL_ANTHROPIC_HAIKU), + implied_provider="anthropic", + ) + openai_model = _ensure_provider_prefix( + _env_or("MODEL_OPENAI", DEFAULT_MODEL_OPENAI), + implied_provider="openai", + ) + anthropic_base = os.environ.get("ANTHROPIC_API_BASE", "").strip() + openai_base = os.environ.get("OPENAI_API_BASE", "").strip() + + def _emit_model( + model_name: str, model: str, key_env: str, base_env: str, has_base: bool + ) -> str: + lines = [ + f" - model_name: {model_name}", + " litellm_params:", + f" model: {model}", + f" api_key: os.environ/{key_env}", + ] + # Only emit api_base when the user actually configured one in .env — + # avoids passing empty string downstream to provider clients. + if has_base: + lines.append(f" api_base: os.environ/{base_env}") + return "\n".join(lines) + + blocks = [ + _emit_model( + "claude-sonnet-cache", sonnet_model, + "ANTHROPIC_API_KEY", "ANTHROPIC_API_BASE", bool(anthropic_base), + ), + _emit_model( + "claude-haiku-cache", haiku_model, + "ANTHROPIC_API_KEY", "ANTHROPIC_API_BASE", bool(anthropic_base), + ), + _emit_model( + "gpt-4o-mini-cache", openai_model, + "OPENAI_API_KEY", "OPENAI_API_BASE", bool(openai_base), + ), + ] + body = ( + "# AUTOGENERATED by e2e/tools/proxy from e2e/.env at proxy-start time.\n" + "# Do not edit by hand — edit e2e/.env and re-run `e2e/tools/proxy start`.\n" + "#\n" + "# Resolved upstream model ids (after auto-prefixing):\n" + f"# claude-sonnet-cache -> {sonnet_model}\n" + f"# claude-haiku-cache -> {haiku_model}\n" + f"# gpt-4o-mini-cache -> {openai_model}\n" + "model_list:\n" + + "\n\n".join(blocks) + + "\n\n" + "litellm_settings:\n" + " callbacks: [\"prometheus\"]\n" + " set_verbose: false\n" + "\n" + "general_settings:\n" + " master_key: sk-e2e-test\n" + ) + RENDERED_CONFIG.write_text(body) + return RENDERED_CONFIG + + +def _compose(*args: str, capture: bool = False) -> subprocess.CompletedProcess: + """Run `docker compose -f ... -p ... `. + + `env_file` is also passed so ${VAR} interpolation inside the compose + file resolves from e2e/.env even when the shell does not have those + vars exported. + """ + cmd = [ + "docker", + "compose", + "-f", + str(COMPOSE_FILE), + "-p", + PROJECT_NAME, + ] + if ENV_FILE.exists(): + cmd.extend(["--env-file", str(ENV_FILE)]) + cmd.extend(args) + if capture: + return subprocess.run(cmd, capture_output=True, text=True, check=False) + return subprocess.run(cmd, check=False) + + +def _readiness_url() -> str: + return f"http://{DEFAULT_HOST}:{DEFAULT_PORT}/health/readiness" + + +def _proxy_url() -> str: + return f"http://{DEFAULT_HOST}:{DEFAULT_PORT}" + + +def _is_ready(timeout: float = 1.5) -> bool: + try: + with urllib.request.urlopen(_readiness_url(), timeout=timeout) as resp: + return resp.status == 200 + except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, OSError): + return False + + +def cmd_start(args: argparse.Namespace) -> int: + if _is_ready(): + print(f"[proxy] already up at {_proxy_url()}", file=sys.stderr) + return 0 + + _load_dotenv() + rendered = render_config() + print(f"[proxy] rendered config -> {rendered}", file=sys.stderr) + print(f"[proxy] starting (compose project={PROJECT_NAME})...", file=sys.stderr) + rc = _compose("up", "-d").returncode + if rc != 0: + return rc + + deadline = time.monotonic() + args.timeout + while time.monotonic() < deadline: + if _is_ready(): + print(f"[proxy] ready at {_proxy_url()}", file=sys.stderr) + print(_proxy_url()) + return 0 + time.sleep(1.0) + + print( + f"[proxy] FAILED to become ready within {args.timeout}s. " + f"Run `e2e/tools/proxy logs` to investigate.", + file=sys.stderr, + ) + return 1 + + +def cmd_stop(_args: argparse.Namespace) -> int: + print(f"[proxy] stopping (compose project={PROJECT_NAME})...", file=sys.stderr) + return _compose("down", "--remove-orphans").returncode + + +def cmd_status(_args: argparse.Namespace) -> int: + if _is_ready(): + print(f"ready {_proxy_url()}") + return 0 + print(f"down {_proxy_url()}", file=sys.stderr) + return 1 + + +def cmd_logs(args: argparse.Namespace) -> int: + extra = [] + if args.tail: + extra.extend(["--tail", str(args.tail)]) + if args.follow: + extra.append("-f") + return _compose("logs", *extra, "litellm").returncode + + +def cmd_rebuild(_args: argparse.Namespace) -> int: + _load_dotenv() + render_config() + print("[proxy] rebuilding image (no cache)...", file=sys.stderr) + rc = _compose("build", "--no-cache").returncode + if rc != 0: + return rc + # Recreate container with the new image. + return _compose("up", "-d", "--force-recreate").returncode + + +def cmd_url(_args: argparse.Namespace) -> int: + print(_proxy_url()) + return 0 + + +def cmd_restart(args: argparse.Namespace) -> int: + cmd_stop(args) + return cmd_start(args) + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + prog="e2e/tools/proxy", + description="docker compose lifecycle for the E2E litellm proxy", + ) + sub = p.add_subparsers(dest="cmd", required=True) + + p_start = sub.add_parser("start", help="bring proxy up in background") + p_start.add_argument( + "--timeout", + type=float, + default=180.0, + help="seconds to wait for /health/readiness. Bumped to 180s because " + "Postgres healthcheck + prisma migrate deploy can add 30-60s on cold " + "start. (default: 180)", + ) + p_start.set_defaults(func=cmd_start) + + sub.add_parser("stop", help="tear proxy down").set_defaults(func=cmd_stop) + + sub.add_parser("status", help="exit 0 if /health/readiness OK").set_defaults( + func=cmd_status + ) + + p_logs = sub.add_parser("logs", help="show container logs") + p_logs.add_argument("--tail", type=int, default=100) + p_logs.add_argument("-f", "--follow", action="store_true") + p_logs.set_defaults(func=cmd_logs) + + sub.add_parser( + "rebuild", help="force image rebuild from current source" + ).set_defaults(func=cmd_rebuild) + + sub.add_parser("url", help="print proxy base URL").set_defaults(func=cmd_url) + + p_restart = sub.add_parser("restart", help="stop + start") + p_restart.add_argument("--timeout", type=float, default=180.0) + p_restart.set_defaults(func=cmd_restart) + + return p + + +def main() -> int: + args = build_parser().parse_args() + return args.func(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/e2e/tools/teams b/e2e/tools/teams new file mode 100755 index 000000000000..fed5a8ae7b41 --- /dev/null +++ b/e2e/tools/teams @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +""" +e2e/tools/teams — team lifecycle wrapper for the E2E proxy. + +Subcommands +----------- + new POST /team/new. Echoes the team_id and metadata. + info GET /team/info?team_id=. + delete POST /team/delete with `{team_ids: []}`. + +Teams are used in case 09 to verify per-team Prometheus label isolation. + +Examples +-------- + e2e/tools/teams new --alias team-a --max-budget 10 + e2e/tools/teams new --alias team-b --models claude-sonnet-cache + e2e/tools/teams delete --team-id +""" +from __future__ import annotations + +import argparse +import json +import os +import sys +import urllib.error +import urllib.request +from typing import Any, Dict, Optional + +DEFAULT_PORT = int(os.environ.get("E2E_PROXY_PORT", "4011")) +DEFAULT_HOST = os.environ.get("E2E_PROXY_HOST", "localhost") +DEFAULT_MASTER_KEY = os.environ.get("LITELLM_MASTER_KEY", "sk-e2e-test") + + +def _http( + method: str, + url: str, + master_key: str, + payload: Optional[Dict[str, Any]] = None, + timeout: float = 30.0, +) -> Dict[str, Any]: + body = json.dumps(payload).encode("utf-8") if payload is not None else None + req = urllib.request.Request( + url, + method=method, + data=body, + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {master_key}", + }, + ) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + return {"status": resp.status, "body": resp.read().decode("utf-8")} + except urllib.error.HTTPError as e: + return {"status": e.code, "body": e.read().decode("utf-8", errors="replace")} + except (urllib.error.URLError, TimeoutError, OSError) as e: + return {"status": 0, "body": json.dumps({"connection_error": str(e)})} + + +def _emit(result: Dict[str, Any]) -> int: + body = result["body"] + try: + parsed = json.loads(body) + except json.JSONDecodeError: + parsed = {"raw": body} + print(json.dumps({"status": result["status"], "response": parsed}, indent=2)) + if result["status"] == 0: + return 3 + if not (200 <= result["status"] < 300): + return 2 + return 0 + + +def cmd_new(args: argparse.Namespace) -> int: + payload: Dict[str, Any] = {} + if args.alias: + payload["team_alias"] = args.alias + if args.team_id: + payload["team_id"] = args.team_id + if args.models: + payload["models"] = args.models + if args.max_budget is not None: + payload["max_budget"] = args.max_budget + if args.tpm_limit is not None: + payload["tpm_limit"] = args.tpm_limit + if args.rpm_limit is not None: + payload["rpm_limit"] = args.rpm_limit + result = _http("POST", f"{args.proxy_url}/team/new", args.master_key, payload) + return _emit(result) + + +def cmd_info(args: argparse.Namespace) -> int: + url = f"{args.proxy_url}/team/info?team_id={args.team_id}" + result = _http("GET", url, args.master_key) + return _emit(result) + + +def cmd_delete(args: argparse.Namespace) -> int: + payload = {"team_ids": [args.team_id]} + result = _http("POST", f"{args.proxy_url}/team/delete", args.master_key, payload) + return _emit(result) + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser(prog="e2e/tools/teams") + p.add_argument("--proxy-url", default=f"http://{DEFAULT_HOST}:{DEFAULT_PORT}") + p.add_argument("--master-key", default=DEFAULT_MASTER_KEY) + sub = p.add_subparsers(dest="cmd", required=True) + + p_new = sub.add_parser("new", help="POST /team/new") + p_new.add_argument("--alias") + p_new.add_argument( + "--team-id", + help="Optional explicit UUID. If omitted, the proxy generates one.", + ) + p_new.add_argument("--models", nargs="*") + p_new.add_argument("--max-budget", type=float) + p_new.add_argument("--tpm-limit", type=int) + p_new.add_argument("--rpm-limit", type=int) + p_new.set_defaults(func=cmd_new) + + p_info = sub.add_parser("info", help="GET /team/info") + p_info.add_argument("--team-id", required=True) + p_info.set_defaults(func=cmd_info) + + p_del = sub.add_parser("delete", help="POST /team/delete") + p_del.add_argument("--team-id", required=True) + p_del.set_defaults(func=cmd_delete) + + return p + + +def main() -> int: + args = build_parser().parse_args() + return args.func(args) + + +if __name__ == "__main__": + sys.exit(main())