What the HTTP surface actually accepts.
- Status legend from
FEATURES.md: ✅ code path plus a gated test, 🟡 code path, no test. - Constrained decoding, tool calling, thinking/reasoning and images:
API_FEATURES.md. - Closed-choice scoring (
/v1/decide,/v1/score):API_SCORING.md.
Two dialects, both native. /v1/messages is implemented against the Anthropic wire format
directly, no shim in either direction. All three dialects share one per-token streaming driver,
so a streaming fix lands in all of them at once.
| endpoint | status | notes |
|---|---|---|
POST /v1/chat/completions |
✅ | the main one. Text, image and video (client frames) content parts |
POST /v1/completions |
✅ | legacy text completion. prompt: a string, a token-id list, or a one-element list of either; a batch of prompts is a 400, like n > 1. suffix = fill-in-the-middle, see FIM |
POST /infill |
✅ | llama.cpp fill-in-the-middle (input_prefix, input_suffix, input_extra, prompt), see FIM |
POST /v1/messages |
✅ | Anthropic. Real per-token SSE, ping keepalives |
POST /v1/messages/count_tokens |
✅ | |
POST /v1/responses |
✅ | OpenAI Responses, the dialect Codex and the Agents SDK speak by default. store: true + previous_response_id continue server-side, see Responses store. Tools: function; custom (Codex apply_patch) as a function with one input string, grammar in its description, returned as custom_tool_call; namespace flattened, calls carry namespace; hosted web_search, tool_search, file_search, code_interpreter, image_generation, computer_use_preview, mcp dropped; any other type 400 (#2807). Replayed calls join the assistant message before them: one response, one assistant turn |
GET /v1/responses/{id}, DELETE /v1/responses/{id} |
✅ | stored responses only, see Responses store |
POST /v1/embeddings |
✅ | needs an embedding model loaded |
POST /v1/rerank, POST /rerank |
✅ | Cohere/Jina/vLLM shape |
POST /v1/decide |
🟡 | closed-choice letter scoring, no decoding; see API_SCORING.md. GPU acceptance: scripts/accept_2198.sh |
POST /v1/score |
🟡 | softmax over caller-given candidate tokens at the last prompt position |
POST /tokenize, POST /detokenize |
✅ | /tokenize takes content (llama.cpp) or prompt (vLLM) |
GET /v1/models |
✅ | loaded model plus the rest of the directory, each with `loaded: true |
GET /health, /metrics, /props, /info |
✅ | /props is the llama.cpp shape, /info the TGI one |
POST /admin/suspend, /admin/resume |
✅ | see DEPLOYMENT.md. --idle-unload-seconds N (default 0 = off) suspends after N s without a request and resumes on the next one, see Admin |
POST /v1/requests/{id}/end_thinking |
✅ | ends a pending or running request's think block; the answer continues on the same KV. id: completion / message / response id or the client X-Request-Id. See API_CONTROL.md |
POST /admin/lora/load, /admin/lora/unload |
🟡 | load or free a PEFT adapter at runtime, see Admin. Validation is gated in the API contract lanes; the device path runs only in scripts/accept_2199.sh (GPU) |
GET / |
✅ | built-in chat UI. Calls GET /v1/models, /props, /info, /health, /metrics, POST /v1/chat/completions, /tokenize, /v1/requests/{id}/end_thinking; features: QUICKSTART.md |
| field | status | notes |
|---|---|---|
model |
✅ | required; basename of the file or directory |
messages, prompt; "continue_final_message": true |
✅ | continue_final_message = assistant prefill (vLLM field, also on /v1/responses): the reply continues the last message, which must be an assistant turn with text and no tool_calls (else 400; add_generation_prompt: true beside it is a 400 too). The prompt is the template's render cut after that text (HF rule). /v1/messages does this for any trailing assistant turn with text. The reply holds only the new text; it is reasoning_content only when the prefix leaves a <think> open |
max_tokens |
✅ | default: the remaining context (--max-tokens N sets a fixed default); on a reasoning model the answer reserve scales with it (max(256, max_tokens/4)) |
temperature, top_p, top_k, min_p |
✅ | imp's defaults are not OpenAI's - see the table below |
presence_penalty, frequency_penalty, repetition_penalty |
✅ | repetition_penalty has no OpenAI field; imp applies 1.05 by default. Ranges: repetition_penalty > 0, min_p 0..1, typical_p in (0, 1], presence/frequency -2..2; outside is a 400 |
seed |
✅ | greedy is reproducible; see DETERMINISM.md for the exact guarantee, which is narrower than "same seed, same bytes" |
stop |
✅ | |
stream |
✅ | per token, all three dialects |
n |
✅ | documented and tested as [1,4] |
logprobs, logit_lens |
✅ | of the processed distribution: the row the sampler drew from, after penalties, logit_bias, banned tokens, a constraint mask, min_p and typical_p (vLLM's processed_logprobs; a masked token reads as probability 0, and at temperature 0 the emitted token is always top_logprobs[0]). tests/test_server_logprobs.py in make test-server pins that with a logit_bias case, plus tests/test_logprobs_shapes.cpp in the CPU lane. Streaming emitted none whenever a stop sequence was set until #1588; /v1/completions returned the Chat shape until #1589. logit_lens (imp, row 115, chat only): true = top 5, 1..20 = top N, implies logprobs; each content entry gains one {rank, logprob, entropy, top} per layer, final norm + LM head on the residual after that layer, raw distribution, rank/logprob of the sampled token, entropy in nats. Decode runs eager while on; a gated-residual model (Qwen4Exp) answers 400 |
prompt_logprobs, echo + logprobs |
✅ | /v1/completions only (#2207). Raw model distribution (log-softmax of the logits), not the processed one logprobs reports. prompt_logprobs: N (vLLM, integer 0..20): choices[0].prompt_logprobs, one entry per prompt token, entry 0 null, each {"<id>": {logprob, rank, decoded_token}} = the prompt token plus the top N. echo: true + logprobs: N: prompt tokens precede completion tokens in choices[0].logprobs; token_logprobs[0] and top_logprobs[0] are null, text_offset indexes prompt + completion text. Either form with stream: true is a 400. Such a request skips prefix-cache reuse and ragged prefill batching (engine_prefill_ragged.cpp prefill_ragged_req_ok_): it prefills alone and forwards every prompt row, the LM head runs as one GEMM per chunk of min(rows, 1024, free VRAM / 2 / 4V) rows on a GGUF dp4a or FP8 head, else in batches of max(max_batch_size, 8) rows (#2257). Device scratch: rows x (12 + 8N) bytes per prefill chunk (168 KiB per 1k rows at N = 20) plus the chunk logits, 4V bytes per row (594 MiB at 1024 x 151936), freed when each chunk's pass returns. Cost on a 2k prompt: scripts/accept_2257.sh C2. tests/test_prompt_logprobs.cpp, tests/api/test_prompt_logprobs.py |
ignore_eos |
✅ | vLLM-compatible extension on chat and completions: EOS and stop tokens are counted as output tokens without text, the think-model implicit \nHuman stop is not injected, user stop strings still apply, the request ends at max_tokens with finish_reason: "length"; for benchmark clients that need equal token counts across arms (tools/analysis/burst_stream_client.py with IGNORE_EOS=1). tests/test_server_ignore_eos.py in make test-server |
best_of |
⚪ | best_of > 1 is a 400: imp generates no candidate set to choose from (#1598) |
DRY, mirostat, typical_p, logit_bias, banned_strings, cfg_scale |
✅ | logit_bias: token-id keys, values in [-100, 100], ids inside the vocabulary; anything else is a 400. banned_strings (imp, row 118, chat; TabbyAPI name): 1..64 strings of <= 256 bytes; a completed phrase rewinds output and KV to the token it starts in, bans that token there and resamples; the last up to 255 bytes are held back until no phrase can still complete. banned_regex (imp, chat): 1..16 PCRE2 patterns of <= 256 bytes, same rewind on the leftmost match that ends in new text inside the last 256 bytes; a pattern that does not compile or matches "" is a 400; holds back 255 bytes. Not with constraints, enforced tool calls or mirostat; the residual KV ring answers 400; hybrids (GDN/SSM) restore a recurrent snapshot (taken after the prefill and every 32 tokens) and re-forward the kept tokens after it; 64 rewinds per request, then phrases pass. cfg_scale (imp, row 117, chat; llama.cpp name): classifier-free guidance in [1, 10], 1 = off; a shadow sequence decodes the conversation without its system message (or with cfg_negative_prompt as system message) in the same step, sampled logits = l_b + s (l_a - l_b). Costs a second sequence of KV and compute; 400 without a system message or negative prompt, with images, video, tools or banned_strings, and on hybrids without runtime.gdn_batched_decode |
"speculative": true/false/{"mtp_k": N} |
✅ | per-request override, carried by all three dialects (chat, /v1/messages, /v1/responses). The boolean form switches every drafter on or off; false reaches all three (n-gram, MTP head, token recycling) since #1639. The object form addresses the trained MTP head alone and is orthogonal to the boolean: {"mtp_k": 0} turns the head off for this request and leaves the matcher running, {"mtp_k": 2} asks for a depth-2 chain. N outside 0..<armed depth> is a 400 naming the range (the bound is the device chain cap, 16, when no head is armed). Neither form can conjure a head the checkpoint lacks or the process did not upload. When a verify step ran, or the request asked for MTP and did not get it, usage.completion_tokens_details carries imp_spec_drafted, imp_spec_accepted, imp_spec_emitted, imp_spec_verify_steps and, on a decline, imp_spec_declined + imp_spec_declined_detail. All six travel on every dialect from one shared table (tools/imp-server/spec_usage_keys.h): Anthropic lifts them into top-level usage, Responses into usage.output_tokens_details. The request span carries the counters as imp.spec_* attributes. Decline reasons: no_mtp_head, mtp_head_not_loaded (the concurrency decline - speculative.mtp_k=auto refuses the head on a server taking concurrent requests), mtp_head_not_armed, mtp_depth_clamped |
"lora": "name" |
✅ | PEFT adapter selected per request, every quant path. One adapter is active at a time: a request naming a different one waits until the in-flight requests finished, then the worker switches (decode graphs re-capture); it is never batched with them. The prefix cache is keyed by adapter, so a shared system prompt is prefilled once per adapter. Adapter shapes are checked against the model at load. A name that is not loaded answers 400 code: lora_not_loaded |
"priority": int |
✅ | vLLM-compatible admission priority, lower value schedules earlier, default 0. Strictly dominates the scheduler's shortest-first-with-aging order; a caller that sets priorities owns starvation across classes. Accepted on all three dialects |
"cache_prompt": true, "session_id": "<id>" |
✅ | session_id (#2407, all four dialects) pins the prompt for the session's next turn until POST /v1/sessions/{id}/close or server.session_ttl_s, see Agent sessions. cache_prompt pins the prompt's full KV blocks against eviction (the OpenAI-route spelling of Anthropic cache_control). Prefix reuse runs regardless: false does not disable it, unlike llama.cpp (usage.prompt_tokens_details.cached_tokens reports the reuse either way) |
A request with no sampling fields uses imp defaults, not OpenAI's (#1596):
they suit local models better than temperature 1.0 with no truncation, so an
identical request returns different output than against the OpenAI API.
| field | imp | OpenAI | to get OpenAI's behaviour |
|---|---|---|---|
temperature |
model default: generation_config.json, else the arch preset (0.6; 1.0 on gpt_oss, qwen4exp), as imp-cli (#2462) |
1.0 | send "temperature": 1.0 |
top_p |
model default (0.95; 0.9 on gemma4, 1.0 on gpt_oss) | 1.0 | send "top_p": 1.0 |
top_k |
model default (20 on the Qwen and gemma4 presets, else 0 = 50) | (no field) | send "top_k": 999999 (see below) |
repetition_penalty |
1.05 | (no field) | send "repetition_penalty": 1.0 |
Two of these do not switch off the way the field name suggests:
-
top_k: 0is not "off", it is 50. Every sampling site spellstop_k > 0 ? top_k : 50(src/exec/executor.cu:219 state.top_k > 0,:289/340,src/runtime/engine_decode_pipeline.cpp:74 r.top_k > 0). -
Zero and "unset" both land on 50.
-
Disabling top-k needs a value at or above the vocabulary size.
-
The dispatcher clamps to the full vocabulary (
engine_decode_pipeline.cpp:75 eff_top_k). -
repetition_penaltyhas no OpenAI field, so a strictly spec-compliant client cannot switch it off. Sending the non-OpenAI field with value1.0disables it: the engine skips the penalty pass when all three penalties are neutral (src/runtime/engine_sampling_stop.cpp:200 needs_penalties = (req.repetition_penalty != 1.0f).
/metrics carries counters (#1640, #1641):
| counter | what moves it |
|---|---|
imp_requests_timed_out_total |
the server ended a request at --request-timeout. The client sees finish_reason: "length", which is also what a spent token budget produces - this counter is the only way to tell them apart |
imp_kv_pressure_rejections_total |
a request was cancelled because the KV pool could not give it blocks (admission or mid-decode). Not incremented for a failed metadata allocation or a snapshot mismatch, which are different faults. imp_kv_swaps_total: decoding requests that found the pool dry and moved their KV to pinned host instead (#2486) |
imp_kv_pool_growths_total |
the growable pool committed more memory. A pool that keeps growing under load is the signal that arrives before it stops being able to |
imp_streaming_kv_auto_enables_total |
the KV pool ran nearly full and StreamingLLM eviction switched itself on (every KV dtype; F16 only before v0.43). The same event demotes CUDA graphs; both are lifted again once a fifth of the pool is free, unless a sequence was actually evicted, which pins them for the rest of the process. usage.prompt_tokens_details.evicted_tokens is the per-request size, this is the rate |
imp_prefix_cache_evictions_total |
a cached prefix block was reclaimed for a new allocation. Rising while imp_tokens_cached_total stalls means the pool is smaller than the working set |
imp_requests_cancelled_total remains client-disconnect only.
Speculative decoding is counted twice: once in aggregate, once per draft source.
- Aggregate:
imp_spec_drafted_total,_accepted_total,_verify_steps_total,_miss_steps_total. - Per source:
imp_spec_mtp_*againstimp_spec_ngram_*, each withdrafted_total,accepted_total,emitted_total,verify_steps_total,verify_wall_ms_total. - Aggregate cannot price the MTP head on its own: the n-gram/suffix matcher, the prompt prediction and token recycling fill the same verify chunk and land in the same totals, so a server running the documented MTP pair reports the same four numbers as one the matcher carried.
imp_spec_mtp_accepted_total / imp_spec_mtp_drafted_total: the head's acceptance rate.imp_spec_mtp_emitted_total / imp_spec_mtp_verify_steps_total: what its verify bought.imp_spec_mtp_verify_wall_ms_total: what it cost.imp_spec_ngram_*: every non-MTP drafter together.- All ten hang off a live engine; a model-less server emits none of them.
Per endpoint, the same counter and ladders carry an endpoint label (chat_completions, completions, messages, responses, embeddings, rerank).
- Labels apply to
imp_endpoint_requests_total,imp_endpoint_request_duration_seconds,imp_endpoint_ttft_seconds,imp_endpoint_inter_token_seconds,imp_endpoint_queue_time_seconds. - Every endpoint emitted at zero, so a panel can be built before traffic arrives.
- The unlabelled series stay the totals.
imp_model_loadedcarriesmodel="<name>", so a swapped model's numbers are not its predecessor's.
imp_decode_batch_last_rows (gauge) is the number of sequences in the most
recent decode step, 0 while the worker idles: the live batch, where
imp_decode_batch_rows_total / imp_decode_batch_steps_total is a windowed
mean and imp_decode_batch_max never resets.
The four latency histograms (imp_request_duration_seconds, imp_ttft_seconds, imp_queue_time_seconds, imp_inter_token_seconds) are fed by every generation path: chat stream and non-stream, /v1/completions stream and non-stream.
- A request cancelled or timed out before the worker admitted it contributes its wait to
imp_queue_time_secondsas well. imp_queue_time_secondsends when the scheduler puts the request into its first batch: the wait behindmax_batch_sizeand KV admission.imp_queue_waiting/imp_queue_runningsplitimp_queue_depthon the same boundary;imp_queued_prompt_tokenscounts prompt tokens not yet past prefill (--max-queued-tokens, #2408).- Gate:
tests/test_server_metrics.pyinmake test-server. - The serving KPI harness reads the histograms and counters back per concurrency level (
tools/analysis/serving_kpi.py, definitions ininternals/BENCHMARKING.md).
imp_kv_blocks_reserved (gauge) is what admission has promised to running
requests for their expected generation (runtime.admission_decode_tokens, #2486) and not yet written (#1635). Free
blocks minus this gauge is what the next request is admitted against, so a
queue in front of a pool with free blocks is explained by this number and by
nothing else in /metrics.
✅ by default. Prefix blocks reused across requests sharing a prefix.
cache_control is honoured per breakpoint: the last marked block bounds the
pinned region, rather than pinning the whole prompt. Usage reporting carries
cache_read_input_tokens and cache_creation_input_tokens.
The cache is keyed on the picture as well as the token ids for image requests, and it is model-fingerprint-gated on disk, so a cache file from another model is never replayed.
Every response echoes back the X-Request-Id header, refusals and unmatched routes included, sanitized to printable ASCII and capped at 128 chars.
- Generation endpoints answer with the server's own completion id when no client id was sent, so every generation response carries some id a caller can quote.
- With
--log-requests, the JSONL record carries the client id asclient_request_idnext to the serverreq_id, the join an agent framework needs to attribute its own latency to this hop. - With
server.otlp_endpointset (the full traces URL of an OTLP/HTTP collector, JSON encoding,http://only), every generation request is exported as an OpenTelemetry span: a SERVER span named after the endpoint (/v1/chat/completions,/v1/messages, ...). - Span attributes:
imp.request_id,imp.client_request_id,gen_ai.request.model,gen_ai.usage.input_tokens/output_tokens,imp.cached_tokens,gen_ai.response.finish_reasons,imp.stream,imp.queue_ms,imp.ttft_ms. - Child spans on the request's timeline:
queue,prefill,decode(the last two for streaming requests, where the first token is observed). - A W3C
traceparentrequest header puts the hop inside the caller's trace (its trace id, the caller's span as parent); without one a trace id is minted. - A header with the sampled flag clear (
...-00) is honoured the way a parent-based sampler would: ids are minted, nothing is exported. - The JSONL record carries the same
trace_id/span_ideither way. - Only requests that reach generation are traced; a request refused before it (4xx, 429, 503) emits no span.
- Export runs on a background thread in one-second batches; an unreachable collector costs one warning and dropped batches, never request latency.
- Measured 2026-09-02 on Qwen3.8-27B: median request latency 113.6 ms with the collector up, 111.5 ms with it stopped.
/metricscountsimp_otlp_spans_exported_total,imp_otlp_export_failures_total,imp_otlp_unsampled_requests_total.- Service name:
server.otlp_service_name(defaultimp-server). - Verified against Jaeger v2.20 (OTLP/HTTP receiver, GenAI view) with an OpenTelemetry-SDK client: the hop lands under the client's span with
queue/prefill/decodechildren on its timeline.
Both sit behind the same --api-key and --rate-limit as every other non-probe route. Set --api-key on any server reachable by others: /admin/lora/load reads a path on the server host.
--idle-unload-seconds N (default 0 = off, #2199)
| item | behaviour |
|---|---|
| trigger | N s since the last admitted POST outside /admin/*, engine queue empty; checked once per second |
| action | the POST /admin/suspend teardown: weights to host RAM, GPU freed |
| next request | resumed from the host snapshot inside that request, then served; its latency includes the resume |
/health |
idle_unload_seconds, idle_suspended |
/ready |
200 while idle-suspended |
| operator suspend | POST /admin/suspend stays sticky: 503 until POST /admin/resume |
| model refused by suspend (501) | idle unload disables itself, logged [idle-unload] disabled |
| WSL2/WDDM | what nvidia-smi reads after the suspend is measured by scripts/accept_2199.sh, see LIMITATIONS.md |
POST /admin/lora/load {"path": "<PEFT dir or .safetensors>", "name": "<optional>"}
| status | when |
|---|---|
200 {"id", "name", "path", "loaded": true} |
loaded; name defaults to the path's last component without extension |
400 lora_load_failed |
missing path, not a PEFT adapter, or shapes do not match the model |
| 400 | path missing or not a non-empty string, name empty |
409 lora_already_loaded |
the name is taken |
| 409 | no model loaded, or operator-suspended |
POST /admin/lora/unload {"id": N} or {"name": "..."}
| status | when |
|---|---|
200 {"id", "name", "unloaded": true} |
device memory freed |
404 lora_not_found |
no such adapter |
| 400 | neither an integer id nor a string name |
- Both drain in-flight requests first (
server.model_swap_drain_ms), never cancel them; 503 if the drain times out. idis stable across suspend/resume; ids are never reused.- Adapters survive an idle or operator suspend: resume re-loads them from their paths.
- A model swap (
server.model_swap) drops every adapter: device memory freed with the old context, one log line each ([model-swap] dropped LoRA adapter '<name>' (id=N, path=...): model swapped). A request naming one then answers 400lora_not_loaded, unload answers 404lora_not_found. Nothing is re-loaded onto the new model, also not when a failed swap restores the previous one:POST /admin/lora/loadagain.
store: true, previous_response_id, GET/DELETE /v1/responses/{id}, the --responses-store-* limits and metrics: API_FEATURES.md (#2206).
Every error is a JSON envelope, never a bare status with an empty body, and
/v1/messages* paths get the Anthropic error shape rather than the OpenAI one.
An unmatched route answers with an envelope too.
Every 4xx logs one WARN line HTTP <status> <method> <path>: <error.message> (#2279). tool_choice refusals (tool_choice_unenforceable, per family): API_FEATURES.md.
On /v1/messages the error.type is always one of Anthropic's own -
invalid_request_error, authentication_error, billing_error,
permission_error, not_found_error, request_too_large, rate_limit_error,
api_error, overloaded_error, timeout_error. Every response from that
endpoint also carries a request-id header, and error bodies repeat it as
request_id.
Stream ending on server-side fault emits error SSE event instead of
message_delta/message_stop.
anthropic-version and anthropic-beta: read and echoed back, not enforced.
Unknown beta logged once per value.
Internal engine errors are translated to ImpError at the C API boundary
(src/api/imp_api.cpp); this is intentional and is why a load failure surfaces
as a typed message rather than a crash.
Readiness as a status code. 200 {"ready": true, ...} only when an inference
request would be taken right now; otherwise 503 with a stable code:
code |
state |
|---|---|
no_model |
started model-less and nothing loaded yet |
engine_faulted |
a device fault poisoned the CUDA context; a swap does not clear it, restart the process |
suspended |
/admin/suspend parked the weights; POST /admin/resume. An idle suspend (--idle-unload-seconds) stays 200 with idle_suspended: true: the next request resumes it |
swapping |
a model load or swap is in progress |
draining |
the process received SIGTERM and is finishing in-flight work |
/health stays liveness and answers 200 in all four (an orchestrator must not
restart a server that is swapping or draining). Both routes need no API key.
/health answers 200 with status: "ok" when process can serve.
Load, queueing, transient OOM: 200 (server alive; restart would worsen them).
503 only for persistent states, with stable code:
code |
what it means | what a client should do |
|---|---|---|
kv_pool_floored |
the KV pool fell back to its rescue floor, so it holds a few hundred tokens instead of a context. Sized once at startup, usually because another process still held the card | do not retry, the process cannot recover. Restart it on a free card |
engine_faulted |
a device fault (illegal address, launch failure) poisoned the CUDA context; the worker stopped | restart |
A device fault is also visible on the request that hit it and on every later
one: non-streaming answers 500 internal_error with code engine_faulted
(engine_step_failed when the step failed on the host and the engine
recovered), a stream carries the same in its error event. No request after
the fault is queued or served.
Whenever a model is loaded the body carries the pool capacity, healthy or
not, so a caller can check a prompt against server capacity without scraping
/metrics:
{"status": "unhealthy", "code": "kv_pool_floored",
"model_loaded": true, "queue_depth": 0, "suspended": false,
"kv_blocks_total": 16, "kv_block_size": 32, "kv_capacity_tokens": 512,
"kv_ceiling_blocks": 16, "kv_pool_growable": false, "kv_pool_bandwidth_gbps": 1554.0}kv_pool_bandwidth_gbps is the pool's own device-to-device copies, captured as one CUDA graph and timed over at least 1 GiB of traffic (best of 3) at init, GB/s counting read plus write (also imp_kv_pool_bandwidth_gbps on /metrics). Before #2369 (5999fd92) the probe timed separate copy submissions and read 63-365 GB/s on resident small pools.
- On WSL2/WDDM a successful allocation proves nothing: a pool the driver spilled into host memory serves at a sixth of the bandwidth with every other signal reading ok.
- Resident memory on the reference card reads 1255-1809 (#2369), host-mapped memory 149-155.
- Below 500 the server logs a WARN at load and keeps serving (the threshold is one driver on one card, so it is a gauge, not a refusal).
- 0 = not measured (a pool too small to time).
kv_ceiling_blocks is what the pool may still grow to.
- Equal to
kv_blocks_total: fixed pool at final size. - Greater than
kv_blocks_total: growable, not there yet (kv_cache.growable). kv_pool_growabledisambiguates the case both report ceiling == total (fixed pool vs growable at its ceiling), which want opposite reactions: wait for the card to free, or stop waiting.- A pool that is small and can still grow is not reported unhealthy: it heals as the card frees; wait for the total to climb rather than restart.