Operator-directed: raise context to the model's native maximum and take as much KV as the card safely allows, and expose the seat through LiteLLM as `gen-large`. max_model_len 131,072 -> 262,144 KV cache 8.76 -> 14.00 GiB (332,721 -> 560,654 tokens) concurrency 2.54x@128K -> 2.14x@262K ⚠ 16.00 GiB WAS TRIED FIRST AND IS TOO AGGRESSIVE. A 155,497-token non-repeating prefill drove GPU 2 to 97,074 of 97,887 MiB and the caching allocator logged "OOM on device 0 while trying to allocate 488636416 bytes (free: 422117376)" -- 466 MiB wanted against 403 MiB free. The request completed, so nothing failed visibly; that is one step before the shape that crashed stacks/mog-sec twice on 2026-09-10 (~1.04 GiB wanted, ~600 MB free). Backed off to 14.00 GiB, which re-probes clean: zero allocator warnings, a 155,557-token prefill in 14.2 s, and 2,085 MiB still free at peak. The reason the first estimate was wrong is worth keeping, because it is not obvious and it inverts the usual advice: --kv-cache-memory makes vLLM SKIP MEMORY PROFILING ENTIRELY and ignore --gpu-memory-utilization. The profiler was the thing accounting for deep-prefill activation, so pinning bytes switched off the protection that the pin was supposed to formalise. vLLM's own "--kv-cache-memory=18745235968 (17.46 GiB) to fully utilize gpu memory" line is computed from a profile measured at max-num-batched-tokens depth and sits 3.5 GiB above what a 150K-token request survives; open #54764 compounds it, since PLE short-conv prefill pads every request in a batch to the batch-MAX query length. max-num-batched-tokens stays at 8192 -- it is what bounds the activation peak, and doubling max_model_len left the profiled peak unchanged at 1.65 GiB precisely because the peak tracks chunk size, not context length. Gateway: `gen-large` added to the LiteLLM model_list, pointing at fv-ml1:8022. One alias on purpose -- a single alias cannot trip the shared-config enable_thinking mutation footgun, which needs two over the same (model, api_base). Sampling is the checkpoint's own declared set (temp 1.0 / top_p 0.95 / top_k 20); presence_penalty, min_p and repetition_penalty are left unset because the checkpoint declares no canonical value for them. Verified registered for both the infra-ops admin key and the shared all-agents key, since a new model behind a scoped allowlist 403s silently. Also adds services/flash-next-mtp-bench/ -- the MTP measurement campaign and its rationale. MTP stays off, but on "not yet measured here" rather than on vLLM's 4xH100 recipe number, which is a cross-harness comparison and not evidence about a TP=1 Blackwell seat.
litellm
OpenAI-compatible gateway in front of the vLLM services on fv-ml1, standing in the request path so every request + response is logged and inspectable in a browser. This is the thing vLLM does not give us: Dozzle shows vLLM's stdout (connection/request metadata) but not the full prompt/completion bodies. LiteLLM captures both, per call, with a Logs UI.
Server: ana-docker (10.250.50.70)
Port: 4000 (proxy API + admin/Logs UI at /ui) — configurable in .env
Backs: the vllm stack on fv-ml1 (10.251.50.54)
Why it exists
phi4-mini is becoming a production summarizer + "dreaming" agent. Being
able to read exactly what it was asked and what it answered is the
difference between debuggable and opaque. See docs/roadmap.md →
"Observability for the vLLM stack". This is the lean first cut of that
roadmap item — see Langfuse-ready below for the upgrade path.
What routes through it
Consumers point their OpenAI base_url at http://10.250.50.70:4000 and
pick a model by name; the gateway forwards to the right vLLM port and
logs the round-trip.
| model name (here) | upstream | vLLM port | logged |
|---|---|---|---|
phi4-mini |
generative chat | :8004 |
full prompt + completion |
qwen3-embedding |
/v1/embeddings |
:8001 |
input + vector metadata |
qwen3-reranker |
/rerank |
:8002 |
query + docs + scores |
Not routed: the vllm-reward Skywork classifier (:8003) is a pooling
/classify endpoint with no first-class LiteLLM route — callers hit it
directly for now. The generative model is the high-value target for
req/resp visibility and it routes cleanly here. (If reward logging is
wanted later, LiteLLM pass_through_endpoints can cover it.)
The log switch
Full prompt/response text shows in the Logs UI because of
store_prompts_in_spend_logs: true in conf/config.yaml. Without it you'd
get metadata only (tokens, latency, model name) — not the text. The
Postgres sidecar (litellm-db) is the store.
Langfuse-ready
This deliberately does not stand up Langfuse's heavy v3 stack (ClickHouse + Redis + MinIO + Postgres + app containers). To graduate to full Langfuse traces later:
- Stand up (or point at) a Langfuse instance.
- Set
LANGFUSE_PUBLIC_KEY/LANGFUSE_SECRET_KEY/LANGFUSE_HOSTin.env. - Uncomment
success_callback/failure_callbackinconf/config.yaml. docker compose up -dto restart.
No re-architecture: the gateway and every consumer stay pointed here.
⚠ reasoning_effort is not a universal vocabulary
gen-reasoning accepts only xhigh (its default), medium and low, and
returns HTTP 400 on anything else:
Unexpected reasoning effort high. Supported types are xhigh (default),
medium, and low.
That is the default value of several clients, so the seat presents as broken
rather than as one enum value out of step. conf/reasoning_effort_map.py is a
pre-call hook that maps high and max onto xhigh for that model group only.
Measured 2026-09-02 across every local seat before scoping it:
| model | reasoning_effort: high |
|---|---|
gen-reasoning |
rejected → mapped |
gen, sec, char-rp-reasoning, summarizer |
accepted → untouched |
Paid passthroughs (gen-frontier*, glm*, kimi*) were deliberately not
probed — they spend vendor credits — and are not mapped. Add a model to
EFFORT_MAP only after measuring that it actually rejects the value.
⚠ A hook file needs a compose change, not just a conf push. Callbacks are
bind-mounted per-file beside config.yaml, so a new hook requires a new volume
line and docker compose up -d litellm (a restart will not pick it up — the
volume only attaches at container creation). Target the service by name; a bare
up -d bounces the DB too.
Deploy
# 1. Sync canonical → ana-docker (compose + conf/config.yaml)
scripts/deploy-stack.sh ana-docker litellm
# 2. On the server: create .env from the template and fill secrets
ssh ana-docker 'cd /opt/docker/compose/litellm && cp -n .env.example .env'
# generate the keys:
# openssl rand -hex 24 | sed 's/^/sk-/' # LITELLM_MASTER_KEY
# openssl rand -hex 32 # LITELLM_SALT_KEY
# openssl rand -hex 24 # POSTGRES_PASSWORD
$EDITOR # fill .env on the server
# 3. Sanity-parse then launch
ssh ana-docker 'cd /opt/docker/compose/litellm && docker compose config >/dev/null && docker compose up -d && docker compose ps'
.env.exampleis the only env file in git. The real.env(master key, salt, Postgres password) lives on the server and is gitignored.
Smoke test
# liveness (no auth)
curl -fsS http://10.250.50.70:4000/health/liveliness # -> "I'm alive!"
# a chat round-trip (uses the master key), then look for it in the Logs UI
curl -s http://10.250.50.70:4000/v1/chat/completions \
-H "Authorization: Bearer $LITELLM_MASTER_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"phi4-mini","messages":[{"role":"user","content":"say hi"}]}'
# embeddings
curl -s http://10.250.50.70:4000/v1/embeddings \
-H "Authorization: Bearer $LITELLM_MASTER_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"qwen3-embedding","input":"hello"}'
Then open http://10.250.50.70:4000/ui (log in with the master key) →
Logs tab → the calls appear with full request + response.
Notes
- Both boxes are Anaheim (
10.250.0.0/16) so the ana-docker → fv-ml1 hop is LAN-local; negligible added latency. VLLM_API_KEYis blank by default because thevllmstack shipsAPI_KEY=empty. Set it here only if you set it there.LITELLM_SALT_KEYmust be set once and never changed — rotating it makes any keys stored in Postgres undecryptable.- Empty
tools: []stripping —conf/strip_empty_tools.pyis a pre-call hook (registered vialitellm_settings.callbacks) that drops an empty/Nonetoolsfield (and any orphanedtool_choice) before forwarding. vLLM 400s ontools: []("tools must not be an empty array");drop_paramsdoesn't catch empty values, only unsupported params. It runs on every request, so all vLLM-backed models are covered, and only fires whentoolsis present-and-empty (real tools pass through untouched). The file mounts at/app/strip_empty_tools.pybesideconfig.yamlbecause LiteLLM resolves callbacks relative to the config dir. Note: real tool-calls additionally need the upstream vLLM server launched with--enable-auto-tool-choice— a vLLM-side flag, separate from this gateway.