Skip to content

Latest commit

 

History

History
300 lines (234 loc) · 28.5 KB

API.md

File metadata and controls

300 lines (234 loc) · 28.5 KB

API

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.

Endpoints

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

Sampling and generation fields

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)

Defaults, and where they differ from OpenAI

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: 0 is not "off", it is 50. Every sampling site spells top_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_penalty has no OpenAI field, so a strictly spec-compliant client cannot switch it off. Sending the non-OpenAI field with value 1.0 disables 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 for what the server decided

/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_* against imp_spec_ngram_*, each with drafted_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_loaded carries model="<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_seconds as well.
  • imp_queue_time_seconds ends when the scheduler puts the request into its first batch: the wait behind max_batch_size and KV admission.
  • imp_queue_waiting / imp_queue_running split imp_queue_depth on the same boundary; imp_queued_prompt_tokens counts prompt tokens not yet past prefill (--max-queued-tokens, #2408).
  • Gate: tests/test_server_metrics.py in make test-server.
  • The serving KPI harness reads the histograms and counters back per concurrency level (tools/analysis/serving_kpi.py, definitions in internals/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.

Prompt caching

✅ 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.

Request tracing

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 as client_request_id next to the server req_id, the join an agent framework needs to attribute its own latency to this hop.
  • With server.otlp_endpoint set (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 traceparent request 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_id either 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.
  • /metrics counts imp_otlp_spans_exported_total, imp_otlp_export_failures_total, imp_otlp_unsampled_requests_total.
  • Service name: server.otlp_service_name (default imp-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 / decode children on its timeline.

Admin: idle unload and LoRA

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.
  • id is 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 400 lora_not_loaded, unload answers 404 lora_not_found. Nothing is re-loaded onto the new model, also not when a failed swap restores the previous one: POST /admin/lora/load again.

Responses store

store: true, previous_response_id, GET/DELETE /v1/responses/{id}, the --responses-store-* limits and metrics: API_FEATURES.md (#2206).

Errors

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.

GET /ready

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.

GET /health, and which 503 is worth retrying

/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_growable disambiguates 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.