tune how a model is served, per model, from the model page (v0.4.84)

Decode on one GPU is bound by how fast the weights can be read: measured at
30 tok/s against a ~28-30 tok/s bandwidth ceiling, it is already at the wall and
no setting moves it. Prefill is not -- it is compute-bound, and on a long prompt
it is nearly all of the request. So the levers that matter are the ones that
avoid work rather than hurry it, and each of them now has a field of its own.

  Prefill batch (--max-num-batched-tokens). vLLM's default is 2048, so a
  60k-token prompt is prefilled in ~30 sequential passes of small GEMMs that
  leave the tensor cores idle. The cost of raising it is a higher activation
  peak, taken out of the KV cache, so the field says to check the boot report
  afterwards rather than implying it is free.

  Prefix caching (--enable-prefix-caching), as a tri-state. vLLM's own default
  is per model -- on for an ordinary decoder, off for a hybrid-attention one,
  where it is supported but still opt-in upstream -- so "leave it alone" has to
  be expressible and has to be the default. For those models this switch is the
  only way to get it, and where the same document is asked about repeatedly it
  turns a whole prefill into almost nothing.

  Speculative decoding (--speculative-config). "ngram" is spelled out as a word
  because it needs no draft model, no second checkpoint and no VRAM: it drafts
  by finding the current text earlier in the prompt, which pays off precisely on
  answers that quote their source. Anything else is passed through as the JSON
  vLLM documents, so a real draft model needs no code change.

  Quantization and dtype, which were accepted by the save route but had no field
  anywhere. The hint says to leave quantization blank: it DECLARES how the
  weights on disk are packed and converts nothing, vLLM reads that from the
  checkpoint and picks its fastest kernel, and a repo whose name says AWQ can
  still be compressed-tensors -- so naming a format can only lose speed or stop
  the engine booting.

  Thinking, per model. A thinking model generates its thought first and charges
  it against max_tokens, so on extraction work it is most of the latency and
  none of the answer; on a tight budget it spends the whole allowance before the
  answer starts and returns an empty one. Until now only a request could turn it
  off, which an install does not control -- its clients do. Three states, since
  unset must never be read as "do not think" for a model whose answers depend on
  it, and an explicit request value still wins either way.

The vLLM card is also shown for a model served by a vLLM POD, which is where
these settings matter most, and that fixes a silent data loss: the card was
hidden for `backend: runpod`, so the collector sent `vllm: {}` and the save route
read an empty block as "this model has no vLLM settings" and deleted the stored
one. Merely opening such a model and saving it dropped its block -- kv-cache
dtype and the cache floors with it -- with no error, and nothing changed until
the next pod booted in a different precision. An engine block is now sent only
when its card was on screen to be edited; otherwise the key is left out, which
the route ignores. The regression test fails on the previous code.
Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S8LAP3ueNzQJtwzhgVbq66
parent a2e3a0f6
......@@ -16,7 +16,7 @@
# Canonical product version for CoderAI — single source of truth. Both the API
# metadata and the admin web UI read from here.
__version__ = "0.4.83"
__version__ = "0.4.84"
__tagline__ = "Complete orchestration, distribution and escalation of remotizable advanced inference."
# Configure the CUDA caching allocator BEFORE torch is imported anywhere.
......
......@@ -2940,6 +2940,11 @@ async def api_model_configure(request: Request, username: str = Depends(require_
"auto_compact_tail_tokens", "auto_compact_tolerance_pct",
"auto_compact_min_output", "auto_compact_retry_on_overflow",
"suppress_reasoning",
# Whether this model thinks when the request does not say.
# "auto" leaves the model's own behaviour; "off" is for a model
# served for extraction, where the thought is charged against
# max_tokens, emitted before the answer, and read by nobody.
"thinking",
# Speech-to-text (wav2vec2 / vosk / NeMo-Canary) options.
"languages", "supports_translation", "keep_resident",
# "local" pins the model here even when its whole capability is
......@@ -3009,7 +3014,9 @@ async def api_model_configure(request: Request, username: str = Depends(require_
"distributed_executor_backend", "ray_address",
"ray_port", "nodes", "nodes_ready_timeout_s",
"gpu", "gpu_memory_utilization", "max_num_seqs",
"dtype", "quantization", "extra_args", "extra_env")),
"dtype", "quantization", "extra_args", "extra_env",
"max_num_batched_tokens", "prefix_caching",
"speculative", "speculative_tokens")),
("kt", ("nnodes", "dist_init_addr", "tp_size", "nodes",
"kt_weight_path", "extra_args", "extra_env"))):
if _blk not in data:
......
......@@ -680,7 +680,26 @@ window.__DEFAULT_WHISPER_SERVER_PATH__ = {{ default_whisper_server_path|tojson }
<textarea id="cfg-vllm-nodes" class="form-input" rows="3" style="font-family:monospace;font-size:11px" placeholder="box2 | ssh box2 docker run -d --rm --name vllm-worker --gpus all --network host -e CODERAI_RAY_ADDRESS={ray_address} ghcr.io/nextime/coderai-vllm:latest | ssh box2 docker stop vllm-worker | 1"></textarea>
<span class="form-hint" style="font-size:11px"><code>{ray_address}</code>, <code>{head}</code> and <code>{port}</code> are filled in. The <code>coderai-vllm</code> image joins as a Ray worker when started with <code>CODERAI_RAY_ADDRESS</code>; every node must run the same vLLM build.</span>
</div>
<div class="form-row" style="margin:.5rem 0 0"><label class="form-label">Extra vLLM args</label><input type="text" id="cfg-vllm-extra" class="form-input" placeholder="--enable-prefix-caching"></div>
<div class="form-row" style="margin:.5rem 0 0"><label class="form-label">Extra vLLM args</label><input type="text" id="cfg-vllm-extra" class="form-input" placeholder="--kv-cache-dtype fp8"></div>
<label class="form-label" style="font-weight:600;margin-top:.75rem">Weights and speed</label>
<span class="form-hint" style="font-size:11px;margin-bottom:.4rem">On one GPU, <b>decode</b> is limited by how fast the weights can be read, so it cannot be tuned past the card's memory bandwidth — only drafting beats that. <b>Prefill</b> can be: it is compute-bound, and a long prompt is where nearly all of a long-context request's time goes.</span>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.6rem">
<div class="form-row" style="margin:0"><label class="form-label">Quantization</label><input type="text" id="cfg-vllm-quant" class="form-input" placeholder="auto-detect (awq/gptq/fp8/…)">
<span class="form-hint" style="font-size:11px">Leave blank. This <i>declares</i> how the weights on disk are already packed — it converts nothing — and vLLM reads that from the checkpoint itself and picks its fastest kernel for it. Naming the wrong format moves the engine onto a slower kernel or stops it booting, and a repo whose <i>name</i> says AWQ can still be compressed-tensors.</span></div>
<div class="form-row" style="margin:0"><label class="form-label">dtype</label><input type="text" id="cfg-vllm-dtype" class="form-input" placeholder="auto (bfloat16/float16)"></div>
<div class="form-row" style="margin:0"><label class="form-label">Prefill batch (tokens per step)</label><input type="number" id="cfg-vllm-mnbt" class="form-input" min="256" step="256" placeholder="vLLM default (2048)">
<span class="form-hint" style="font-size:11px">vLLM prefills this many tokens per engine step, so at the default a 60k-token prompt takes ~30 sequential passes of small, tensor-core-starved GEMMs. Raising it (8192 is a good first try) cuts time-to-first-token on long prompts. The cost comes out of the KV cache: check <code>Available KV cache memory</code> and the concurrency in the boot log afterwards.</span></div>
<div class="form-row" style="margin:0"><label class="form-label">Prefix caching</label>
<select id="cfg-vllm-prefix" class="form-input">
<option value="">auto — vLLM's default for this model</option>
<option value="on">on — reuse prefixes already computed</option>
<option value="off">off</option>
</select>
<span class="form-hint" style="font-size:11px">Reuses the KV of a prompt prefix that was computed before, which turns a whole prefill into almost nothing when the same document is asked about again and again. vLLM's own default is on for ordinary models but <b>off for hybrid-attention ones</b>, where it is supported yet still opt-in upstream — so for those this switch is the only way to get it. Worth re-running a retrieval check after turning it on.</span></div>
<div class="form-row" style="margin:0"><label class="form-label">Speculative decoding</label><input type="text" id="cfg-vllm-spec" class="form-input" placeholder="off — or ngram, or JSON">
<span class="form-hint" style="font-size:11px"><code>ngram</code> needs no draft model and no VRAM: it guesses the next tokens by finding the current text earlier in the prompt. That pays off precisely when the answer quotes its source — extraction, citation, summarising a document — and does nothing for free-form prose. Anything else is passed to vLLM as <code>--speculative-config</code> JSON, so a real draft model can be configured here too.</span></div>
<div class="form-row" style="margin:0"><label class="form-label">Draft tokens <span class="muted">(ngram)</span></label><input type="number" id="cfg-vllm-spec-tokens" class="form-input" min="1" max="32" placeholder="5"></div>
</div>
</div>
<div class="form-row" id="cfg-longcat-row" style="margin-top:.75rem;display:none;border:1px solid var(--border,#333);border-radius:8px;padding:.6rem">
<label class="form-label" style="font-weight:600">LongCat-Video <span class="muted" style="font-weight:400">— stages, segments and the isolated runtime</span></label>
......@@ -1432,6 +1451,18 @@ window.__DEFAULT_WHISPER_SERVER_PATH__ = {{ default_whisper_server_path|tojson }
<span class="form-hint" style="font-size:11px">By default the model's thinking is returned as a separate <code>reasoning</code> / <code>reasoning_content</code> field (think/thinking/thought tags, including the bare <code>&lt;/think&gt;</code> Qwen pre-fill emits) and kept out of <code>content</code>. Enable this to discard it entirely. A request can override per-call with <code>reasoning:{exclude:true}</code>, <code>reasoning_effort:"none"</code>, or <code>suppress_reasoning</code>.</span>
</div>
<!-- Whether the model THINKS at all, as opposed to whether its thinking is
returned. Suppressing the output still pays for the tokens. -->
<div style="margin-top:.75rem;max-width:46rem">
<label class="form-label">Thinking</label>
<select id="cfg-thinking" class="form-input">
<option value="">auto — the model's own behaviour</option>
<option value="off">off — do not think unless the request asks</option>
<option value="on">on — think unless the request says otherwise</option>
</select>
<span class="form-hint" style="font-size:11px">A thinking model generates its thought <i>first</i> and charges it against <code>max_tokens</code>, so on extraction and retrieval work it is most of the latency and none of the answer — and on a tight budget it can consume the whole allowance before the answer begins, returning an empty one. This is the default for requests that say nothing; <code>enable_thinking</code> on the request always wins. Different from <b>Suppress reasoning output</b> above, which still pays for the tokens and merely hides them.</span>
</div>
<div style="display:flex;gap:1.5rem;flex-wrap:wrap;margin-top:.75rem">
<label style="display:flex;align-items:center;gap:.5rem;cursor:pointer;font-size:13px"><input type="checkbox" id="cfg-4bit"> 4-bit quantization</label>
<label style="display:flex;align-items:center;gap:.5rem;cursor:pointer;font-size:13px"><input type="checkbox" id="cfg-8bit"> 8-bit quantization</label>
......@@ -4224,6 +4255,9 @@ function openCfgModal(idx, cfgIdx){
document.getElementById('cfg-autocompact-tail-tok').value = s.auto_compact_tail_tokens || 0;
_populateCompactModelSelect(s.auto_compact_model || '');
document.getElementById('cfg-suppress-reasoning').checked = !!s.suppress_reasoning;
// Stored as a string; older entries may hold a bool.
(function(){ const t = s.thinking; const el = document.getElementById('cfg-thinking');
if (el) el.value = (t === true || t === 'on') ? 'on' : (t === false || t === 'off') ? 'off' : ''; })();
_toggleAutoCompact();
document.getElementById('cfg-4bit').checked = !!s.load_in_4bit;
document.getElementById('cfg-8bit').checked = !!s.load_in_8bit;
......@@ -4678,7 +4712,12 @@ function _applyCompat(){
const rrow = document.getElementById('cfg-rpc-row');
if (rrow) rrow.style.display = (_requiredCap(backend) === 'gguf' && backend !== 'runpod' && backend !== 'host') ? '' : 'none';
const vrow = document.getElementById('cfg-vllm-row');
if (vrow) vrow.style.display = (backend === 'vllm') ? '' : 'none';
// Also for a model served by a vLLM POD: the settings describe how vLLM runs
// the model, and it is the rented card — not this machine — that most needs
// them tuned. While this card was hidden for runpod models, their vLLM block
// was unreachable from here and _collectCluster sent it back empty, which
// DELETED it on save (kv-cache dtype, prefix caching, the lot).
if (vrow) vrow.style.display = _vllmBlockApplies() ? '' : 'none';
const krow = document.getElementById('cfg-kt-row');
if (krow) krow.style.display = (backend === 'kt') ? '' : 'none';
const lcrow = document.getElementById('cfg-longcat-row');
......@@ -4712,6 +4751,11 @@ function _populateCluster(m, s){
set('cfg-vllm-executor', v.distributed_executor_backend || ''); set('cfg-vllm-ray', v.ray_address || '');
set('cfg-vllm-gmu', v.gpu_memory_utilization || ''); set('cfg-vllm-extra', v.extra_args || '');
set('cfg-vllm-kv-target', v.kv_target_gb || ''); set('cfg-vllm-kv-conc', v.kv_min_concurrency != null ? v.kv_min_concurrency : '');
set('cfg-vllm-quant', v.quantization || ''); set('cfg-vllm-dtype', v.dtype || '');
set('cfg-vllm-mnbt', v.max_num_batched_tokens || '');
// "auto" is stored by older entries; the option that means it has a blank value.
set('cfg-vllm-prefix', (v.prefix_caching && v.prefix_caching !== 'auto') ? v.prefix_caching : '');
set('cfg-vllm-spec', v.speculative || ''); set('cfg-vllm-spec-tokens', v.speculative_tokens || '');
set('cfg-vllm-nodes', _nodesToLines(v.nodes, true));
// LongCat-Video. Read from the FLAT entry, not a nested block: these are per-model
// settings the worker reads off the entry, the same shape as the offload/quant fields.
......@@ -4746,6 +4790,16 @@ function _nodesToLines(nodes, withGpus){
if (typeof nodes === 'string') return nodes;
return (nodes || []).map(n => [n.name || '', n.start_cmd || '', n.stop_cmd || ''].concat(withGpus ? [n.gpus || ''] : []).join(' | ').replace(/( \| )+$/, '')).join('\n');
}
//: Engines that are vLLM under another name. "auto" sends anything that is not
//: a GGUF to vLLM, so a model on auto gets the card too.
const _VLLM_POD_ENGINES = ['auto', 'vllm', 'coderai-vllm'];
function _vllmBlockApplies(){
const backend = ((document.getElementById('cfg-backend') || {}).value || 'auto');
if (backend === 'vllm') return true;
if (backend !== 'runpod') return false;
const eng = ((document.getElementById('cfg-rp-engine') || {}).value || 'auto');
return _VLLM_POD_ENGINES.includes(eng);
}
function _collectCluster(){
const val = id => ((document.getElementById(id) || {}).value || '').trim();
const num = id => { const v = parseFloat(val(id)); return isNaN(v) ? '' : v; };
......@@ -4788,17 +4842,32 @@ function _collectCluster(){
out.use_distill = chk('cfg-longcat-distill');
out.offload_kv_cache = chk('cfg-longcat-offload-kv');
}
out.vllm = backend === 'vllm' ? {
// An engine block is only SENT when its card was on screen to be edited.
// Sending an empty block is not neutral -- the save route reads it as "this
// model has no vLLM settings" and drops the stored one -- so a card that was
// never shown must leave the key out instead, which the route ignores.
if (_vllmBlockApplies()) {
out.vllm = {
tensor_parallel_size: num('cfg-vllm-tp'), pipeline_parallel_size: num('cfg-vllm-pp'),
distributed_executor_backend: val('cfg-vllm-executor'), ray_address: val('cfg-vllm-ray'),
gpu_memory_utilization: num('cfg-vllm-gmu'), extra_args: val('cfg-vllm-extra'),
kv_target_gb: num('cfg-vllm-kv-target'), kv_min_concurrency: num('cfg-vllm-kv-conc'),
nodes: val('cfg-vllm-nodes'),
} : {};
out.kt = backend === 'kt' ? {
// Weights-on-disk format (blank = auto-detect, which picks the fastest
// kernel vLLM has) and the speed levers.
quantization: val('cfg-vllm-quant'), dtype: val('cfg-vllm-dtype'),
max_num_batched_tokens: num('cfg-vllm-mnbt'),
prefix_caching: val('cfg-vllm-prefix'),
speculative: val('cfg-vllm-spec'),
speculative_tokens: num('cfg-vllm-spec-tokens'),
};
}
if (backend === 'kt') {
out.kt = {
nnodes: num('cfg-kt-nnodes'), tp_size: num('cfg-kt-tp'), dist_init_addr: val('cfg-kt-dist'),
extra_args: val('cfg-kt-extra'), nodes: val('cfg-kt-nodes'),
} : {};
};
}
return out;
}
......@@ -5079,6 +5148,9 @@ function _updateRunpodSource(){
}
function _updateRunpodEngineHint(){
// The vLLM card's visibility depends on the pod's engine as well as the
// backend, so changing the engine has to re-decide it.
try { _applyCompat(); } catch (e) {}
const el = document.getElementById('cfg-rp-engine');
const hint = document.getElementById('cfg-rp-engine-hint');
if (!el || !hint) return;
......@@ -5268,6 +5340,7 @@ async function saveModelConfig(){
auto_compact_tail_tokens: parseInt(document.getElementById('cfg-autocompact-tail-tok').value) || 0,
auto_compact_model: document.getElementById('cfg-autocompact-model').value || '',
suppress_reasoning: document.getElementById('cfg-suppress-reasoning').checked,
thinking: ((document.getElementById('cfg-thinking') || {}).value || '') || undefined,
max_gpu_percent: isNaN(maxGpu) ? null : maxGpu,
manual_ram_gb: isNaN(ramGb) ? null : ramGb,
load_in_4bit: document.getElementById('cfg-4bit').checked,
......
......@@ -1121,6 +1121,11 @@ _ENGINE_FORWARDED_FIELDS = {
"kt": ("ctx", "extra_args", "extra_env", "model_id"),
"vllm": ("ctx", "gpu_memory_utilization", "tensor_parallel_size", "max_num_seqs",
"dtype", "quantization", "extra_args", "extra_env",
# The speed levers describe how to RUN the model, so they belong on
# the pod that runs it -- a prefill budget tuned for an A40 is
# useless if it stays on the orchestrator.
"max_num_batched_tokens", "prefix_caching",
"speculative", "speculative_tokens",
# Only ever forwarded when TRUE: the loop below skips False, so
# adding it here cannot change an install that has not asked for it.
# A pod needs it to recover a byte-level vocabulary's characters from
......@@ -1792,6 +1797,10 @@ def seed_model_env(entry: dict, served: str = "", source: str = "",
# context and memory shape describe the MODEL, and a pod that does
# not get them serves it differently from this install.
"vllm",
# Whether the model thinks by default. A pod leased to a client
# answers its requests directly, so a model configured not to think
# must not start thinking just because it moved.
"thinking",
"flash_attention", "model_template", "acceleration", "component_quantization",
"languages", "supports_translation", "parser", "max_instances",
# LoRA settings travel too: a coderai pod applies them itself, and
......
......@@ -1654,6 +1654,15 @@ async def chat_completions(request: ChatCompletionRequest, http_request: Request
# thinking model thinks by default, and only an explicit false asks it to
# stop. The two were indistinguishable while this field defaulted to False.
enable_thinking_req = getattr(request, 'enable_thinking', None)
# Still nothing from the caller? Then the MODEL's own default decides. A
# thinking model charges its thought against max_tokens and emits it first,
# so on extraction and retrieval work the thought is most of the latency and
# none of the answer -- long enough, on a long prompt, to spend the whole
# budget before the answer starts. An install that serves such a model for
# that kind of work needs to be able to say so once, here, instead of
# relying on every client to pass enable_thinking=false.
if enable_thinking_req is None:
enable_thinking_req = _model_thinking_default(getattr(request, 'model', None) or "")
enable_thinking_api = bool(enable_thinking_req)
# Parse force_reasoning: can be list (from CLI) or string (legacy)
......@@ -2618,7 +2627,7 @@ async def chat_completions(request: ChatCompletionRequest, http_request: Request
frequency_penalty=request.frequency_penalty,
logprobs=request.logprobs,
top_logprobs=request.top_logprobs,
passthrough=_chat_passthrough(request),
passthrough=_chat_passthrough(request, enable_thinking_req),
):
yield chunk
finally:
......@@ -2649,7 +2658,7 @@ async def chat_completions(request: ChatCompletionRequest, http_request: Request
frequency_penalty=request.frequency_penalty,
logprobs=request.logprobs,
top_logprobs=request.top_logprobs,
passthrough=_chat_passthrough(request),
passthrough=_chat_passthrough(request, enable_thinking_req),
)
try:
......@@ -2709,7 +2718,38 @@ _CHAT_CONSUMED_FIELDS = frozenset({
})
def _chat_passthrough(request) -> Optional[Dict]:
#: Per-model thinking defaults, as the model page spells them.
_THINKING_ON = ("on", "true", "1", "yes", "always")
_THINKING_OFF = ("off", "false", "0", "no", "never")
def _model_thinking_default(model_name: str):
"""Whether this model should think when the request does not say, or None.
None means "leave the model's own default", which for a thinking model means
it thinks -- deliberately distinct from False, which asks it not to. Only a
configured value speaks here; an unset one must not turn thinking off for a
model whose answers depend on it.
"""
if not model_name:
return None
try:
from codai.models.manager import multi_model_manager as _mmm
cfg = _mmm._config_for_model(model_name) or {}
except Exception:
return None
raw = cfg.get("thinking")
if isinstance(raw, bool):
return raw
val = str(raw or "").strip().lower()
if val in _THINKING_ON:
return True
if val in _THINKING_OFF:
return False
return None
def _chat_passthrough(request, want_think=None) -> Optional[Dict]:
"""The request fields coderai does not consume, for the backend to forward.
Only reaches a backend whose signature accepts it (see
......@@ -2726,6 +2766,10 @@ def _chat_passthrough(request) -> Optional[Dict]:
# model's own default, which for a thinking model means it thinks.
# Without this, a request could not turn thinking off on a served model
# at all: coderai consumed the field and no backend forwarded it.
# The caller's own value, else the one the model was configured with —
# already resolved by the caller, so the request path and this one cannot
# disagree about whether the model is meant to think.
if want_think is None:
want_think = getattr(request, "enable_thinking", None)
if want_think is not None and "chat_template_kwargs" not in out:
out["chat_template_kwargs"] = {"enable_thinking": bool(want_think)}
......
......@@ -293,6 +293,42 @@ def _reasoning_args(cfg, model_config: dict, model_path: str) -> list:
return ["--reasoning-parser", parser]
#: Draft length for "ngram" when none was configured. Five is vLLM's own choice
#: for the lookup window; drafting much further ahead wastes the verify pass on
#: tokens the model was never going to accept.
_NGRAM_DRAFT_TOKENS = 5
def _speculative_args(cfg) -> list:
"""``--speculative-config`` for the configured mode, or nothing.
"ngram" is spelled out here rather than left to the operator as JSON because
it is the mode that needs no draft model, no second checkpoint and no VRAM,
and so is the only one that can be turned on from a dropdown. Any other value
is taken as the JSON vLLM itself documents, which keeps a real draft model
reachable without waiting for a code change.
"""
spec = (getattr(cfg, "speculative", "") or "").strip()
if not spec:
return []
if spec.lower() == "ngram":
n = int(getattr(cfg, "speculative_tokens", 0) or 0) or _NGRAM_DRAFT_TOKENS
body = {"method": "ngram", "num_speculative_tokens": n}
else:
try:
body = json.loads(spec)
except Exception as exc:
# A typo here would otherwise stop the engine booting with an
# argparse error about JSON, naming neither the model nor the field.
print(f"[vllm] speculative config ignored, not valid JSON: {exc}", flush=True)
return []
if not isinstance(body, dict):
print(f"[vllm] speculative config ignored, not an object: {spec!r}", flush=True)
return []
print(f"[vllm] speculative decoding: {body}", flush=True)
return ["--speculative-config", json.dumps(body)]
def _launch_cmd(py, cfg, host: str, port: int, model_path: str,
served_name: Optional[str] = None, model_config: dict = None,
gpu_memory_utilization: Optional[float] = None,
......@@ -320,7 +356,10 @@ def _launch_cmd(py, cfg, host: str, port: int, model_path: str,
# The profiling peak that decides whether the engine can start is driven by how many
# tokens may be in one batch, NOT by max_model_len (chunked prefill is on), so this
# is the lever that shrinks the footprint without shrinking the usable context.
mnbt = int(max_num_batched_tokens or 0)
# A caller's figure (a side job sizing itself down) wins; otherwise the
# configured one, which is how an install raises the budget to speed up long
# prefills instead of only ever lowering it to fit.
mnbt = int(max_num_batched_tokens or 0) or int(getattr(cfg, "max_num_batched_tokens", 0) or 0)
if mnbt > 0:
cmd += ["--max-num-batched-tokens", str(mnbt)]
tp = int(getattr(cfg, "tensor_parallel_size", 0) or 0)
......@@ -343,6 +382,16 @@ def _launch_cmd(py, cfg, host: str, port: int, model_path: str,
quant = (getattr(cfg, "quantization", "") or "").strip()
if quant:
cmd += ["--quantization", quant]
# Prefix caching. Only ever passed when the operator asked for a value:
# vLLM's own default is per MODEL (on for a plain decoder, off for a hybrid
# one, where it is supported but kept opt-in upstream), and passing a flag
# unasked would override that judgement for every model at once.
pc = (getattr(cfg, "prefix_caching", "") or "auto").strip().lower()
if pc in ("on", "true", "1", "yes"):
cmd += ["--enable-prefix-caching"]
elif pc in ("off", "false", "0", "no"):
cmd += ["--no-enable-prefix-caching"]
cmd += _speculative_args(cfg)
# LoRA adapters configured on the MODEL (not on the vLLM backend): vLLM takes
# them at launch as name=source pairs, where source is a path on this host or
# a HuggingFace repo id. A QLoRA adapter is an ordinary LoRA here — the
......
......@@ -25,6 +25,13 @@ VLLM_FIELDS = ("ctx", "gpu", "gpu_memory_utilization",
"pipeline_parallel_size", "distributed_executor_backend", "ray_address",
"ray_port", "nodes", "nodes_ready_timeout_s", "max_num_seqs", "dtype",
"quantization", "extra_args", "extra_env",
# Speed levers, per model because they trade against each other
# differently for each one: a bigger prefill batch buys
# time-to-first-token out of the KV cache, prefix caching pays
# only where prompts repeat, and ngram drafting pays only where
# the answer quotes the prompt.
"max_num_batched_tokens", "prefix_caching",
"speculative", "speculative_tokens",
# Which parser reads this model's tool-call syntax. Per model,
# because the syntax belongs to the model family and one install
# serves several.
......
......@@ -813,7 +813,39 @@ class VllmConfig:
tensor_parallel_size: int = 1 # --tensor-parallel-size
max_num_seqs: int = 0 # --max-num-seqs (0 = vLLM default)
dtype: str = "" # --dtype (blank = auto; e.g. bfloat16/float16)
quantization: str = "" # --quantization (blank = none; e.g. awq/gptq/fp8)
# --quantization. Blank means AUTO-DETECT, which is almost always what you
# want: vLLM reads the checkpoint's own quantization_config and picks the
# fastest kernel it has for it (a compressed-tensors W4A16 checkpoint gets
# Marlin). This field DECLARES how the weights on disk are packed -- it does
# not convert anything -- so naming the wrong format moves the engine onto a
# slower kernel or stops it booting. A checkpoint whose name says AWQ can
# still be compressed-tensors; believe the config, not the repo name.
quantization: str = ""
# How many tokens may be prefilled in ONE engine step (--max-num-batched-tokens).
# vLLM's own default is 2048, so a 60k-token prompt is prefilled as ~30
# sequential chunks of small, tensor-core-starved GEMMs. Raising it fills the
# cards better and cuts time-to-first-token on long prompts; the cost is a
# higher activation peak at profiling, which is taken out of the KV cache --
# so raise it watching `Available KV cache memory` and the concurrency the
# boot report prints. 0 = leave vLLM's default.
max_num_batched_tokens: int = 0
# Reuse the KV of a prompt prefix already computed (--enable-prefix-caching).
# "auto" leaves vLLM's per-model default, which is ON for ordinary decoder
# models but OFF for hybrid-attention ones (supported there, but opt-in while
# the feature matures upstream). "on" forces it, which is the one setting that
# can take a repeated-document workload -- the same file asked about again and
# again -- from a full prefill per question to almost none. "off" forces it off.
prefix_caching: str = "auto" # auto | on | off
# Speculative decoding (--speculative-config). Decode on one GPU is bound by
# weight bandwidth, not arithmetic, so drafting several tokens and checking
# them in one pass is the only way to beat that ceiling. "ngram" needs no
# draft model and no VRAM: it drafts by finding the current suffix earlier in
# the prompt, which is why it pays off exactly on answers that quote their
# source (extraction, citation, summarising a document) and does nothing for
# free-form prose. Anything else is passed through as raw JSON, so a draft
# model can be configured without a code change.
speculative: str = "" # "" | "ngram" | raw JSON for --speculative-config
speculative_tokens: int = 0 # draft length for "ngram" (0 = built-in default)
# Tool calling. vLLM parses a model's tool-call syntax with a named parser
# and REFUSES a request carrying `tools` unless it was launched with one:
# "auto" tool choice requires --enable-auto-tool-choice and
......
"""A model can be configured not to think, without every client saying so.
A thinking model generates its thought FIRST and charges it against max_tokens.
On extraction and retrieval work that thought is most of the latency and none of
the answer, and on a tight budget it is worse than slow: the allowance runs out
inside the thought and the answer comes back empty, with the request reporting
that it merely stopped. Measured on a real one -- a 2,100-token budget, ~6,000
characters of thought, no visible answer at all.
Until now the only way to turn it off was `enable_thinking: false` on every
request, which an install does not control: its clients do. So the model itself
carries a default.
Three states, not two. "auto" has to mean "leave the model's own behaviour" and
has to be what an unconfigured model gets, because for a reasoning model the
thought is the answer's quality -- an unset field must never be read as "off".
An explicit request value always wins over the configured default, in both
directions: that is what makes the setting a default rather than a policy.
"""
import pytest
class _Req:
"""Only what the code under test reads off a request."""
def __init__(self, model="m", enable_thinking=None, extra=None):
self.model = model
self.enable_thinking = enable_thinking
self.model_extra = extra or {}
@pytest.fixture
def configured(monkeypatch):
"""Point the per-model lookup at a config this test chooses."""
def _set(cfg):
from codai.models import manager as mgr
monkeypatch.setattr(mgr.multi_model_manager, "_config_for_model",
lambda name: dict(cfg), raising=False)
return _set
def _default(name="m"):
from codai.api.text import _model_thinking_default
return _model_thinking_default(name)
# -- resolving the configured default ------------------------------------
def test_an_unconfigured_model_keeps_its_own_behaviour(configured):
configured({})
assert _default() is None, "unset must not be read as 'do not think'"
def test_auto_keeps_the_models_own_behaviour(configured):
configured({"thinking": "auto"})
assert _default() is None
@pytest.mark.parametrize("value", ["off", "false", "no", "never", "0", False])
def test_off_asks_the_model_not_to_think(configured, value):
configured({"thinking": value})
assert _default() is False
@pytest.mark.parametrize("value", ["on", "true", "yes", "always", "1", True])
def test_on_asks_the_model_to_think(configured, value):
configured({"thinking": value})
assert _default() is True
def test_an_unknown_value_is_not_guessed(configured):
configured({"thinking": "sometimes"})
assert _default() is None
def test_no_model_name_consults_nothing(configured):
configured({"thinking": "off"})
assert _default("") is None
def test_a_broken_config_lookup_leaves_the_model_alone(monkeypatch):
from codai.models import manager as mgr
def _boom(name):
raise RuntimeError("models.json is being rewritten")
monkeypatch.setattr(mgr.multi_model_manager, "_config_for_model", _boom,
raising=False)
assert _default() is None
# -- what reaches the engine ---------------------------------------------
def _kwargs(request, want_think):
from codai.api.text import _chat_passthrough
return (_chat_passthrough(request, want_think) or {}).get("chat_template_kwargs")
def test_the_default_travels_as_a_template_argument():
"""Only the chat template can decide whether the model thinks."""
assert _kwargs(_Req(), False) == {"enable_thinking": False}
def test_thinking_on_travels_too():
assert _kwargs(_Req(), True) == {"enable_thinking": True}
def test_nothing_configured_sends_nothing():
assert _kwargs(_Req(), None) is None
def test_an_explicit_request_value_wins_over_the_default():
"""A client asking to think must get it, whatever the model is set to."""
assert _kwargs(_Req(enable_thinking=True), True) == {"enable_thinking": True}
assert _kwargs(_Req(enable_thinking=False), False) == {"enable_thinking": False}
def test_a_callers_own_template_kwargs_are_not_overwritten():
req = _Req(extra={"chat_template_kwargs": {"enable_thinking": True}})
assert _kwargs(req, False) == {"enable_thinking": True}
def test_enable_thinking_is_not_forwarded_as_a_bare_field():
"""vLLM takes it as a template argument; as a top-level field it is a 400."""
from codai.api.text import _chat_passthrough
out = _chat_passthrough(_Req(enable_thinking=False), False) or {}
assert "enable_thinking" not in out
"""Per-model vLLM speed settings: they reach the engine, and saving keeps them.
Two separate things are pinned here.
THE FLAGS. Decode on one card is bound by how fast the weights can be read, so
the only levers worth having are the ones that avoid work rather than speed it
up: prefill a bigger batch per step, reuse a prefix already computed, and draft
tokens from the prompt. Each is a flag vLLM only honours if it was launched with
it, so each is asserted on the launch command rather than on the config object.
Prefix caching is a tri-state on purpose. vLLM's own default is per MODEL -- on
for an ordinary decoder, off for a hybrid-attention one, where the feature is
supported but kept opt-in upstream -- so "leave it alone" has to be expressible
and has to be the default. A flag passed unasked would overrule that judgement
for every model at once.
THE SAVE. The model page used to send `vllm: {}` for any model whose backend was
not literally "vllm", and the save route reads an empty block as "this model has
no vLLM settings" and deletes the stored one. A pod-served model is exactly the
case that was hit: its card was hidden, so merely opening the model and saving it
dropped its block -- kv-cache dtype, cache floors and all -- with no error and no
visible change until the next pod booted differently. The rule now is that a
block is only sent when its card was on screen to be edited; otherwise the key is
left out, which the route ignores.
"""
import json
import pathlib
import re
import shutil
import subprocess
import types
import pytest
ROOT = pathlib.Path(__file__).resolve().parents[1]
PAGE = ROOT / "codai/admin/templates/models.html"
# --------------------------------------------------------------------------
# the launch command
# --------------------------------------------------------------------------
def _cfg(**kw):
"""A vLLM config carrying only what a test sets, like a real override."""
base = dict(ctx=0, gpu_memory_utilization=0, tensor_parallel_size=0,
pipeline_parallel_size=0, distributed_executor_backend="",
max_num_seqs=0, dtype="", quantization="",
max_num_batched_tokens=0, prefix_caching="auto",
speculative="", speculative_tokens=0,
tool_call_parser="off", reasoning_parser="off",
return_token_ids=False, extra_args="")
base.update(kw)
return types.SimpleNamespace(**base)
def _cmd(**kw):
from codai.api import vllm_worker
return vllm_worker._launch_cmd("py", _cfg(**kw), "127.0.0.1", 8000,
"org/model", served_name="m", model_config={})
def _flag_value(cmd, flag):
return cmd[cmd.index(flag) + 1] if flag in cmd else None
def test_prefill_batch_is_passed_when_configured():
"""vLLM's default is 2048, so a 60k prompt is ~30 sequential passes."""
assert _flag_value(_cmd(max_num_batched_tokens=8192),
"--max-num-batched-tokens") == "8192"
def test_a_callers_own_prefill_batch_still_wins():
"""A side job sizing itself down must not be overruled by the config."""
from codai.api import vllm_worker
cmd = vllm_worker._launch_cmd("py", _cfg(max_num_batched_tokens=8192),
"127.0.0.1", 8000, "org/model",
served_name="m", model_config={},
max_num_batched_tokens=2048)
assert _flag_value(cmd, "--max-num-batched-tokens") == "2048"
def test_prefill_batch_is_absent_by_default():
assert "--max-num-batched-tokens" not in _cmd()
@pytest.mark.parametrize("value", ["auto", "", None])
def test_prefix_caching_unset_passes_no_flag(value):
"""The model's own default must survive; silence is not "off"."""
cmd = _cmd(prefix_caching=value)
assert "--enable-prefix-caching" not in cmd
assert "--no-enable-prefix-caching" not in cmd
def test_prefix_caching_on_is_forced_explicitly():
"""The only way to get it on a hybrid model, where vLLM defaults it off."""
assert "--enable-prefix-caching" in _cmd(prefix_caching="on")
def test_prefix_caching_off_is_forced_explicitly():
cmd = _cmd(prefix_caching="off")
assert "--no-enable-prefix-caching" in cmd
assert "--enable-prefix-caching" not in cmd
def test_ngram_needs_no_draft_model_and_no_json_from_the_operator():
cmd = _cmd(speculative="ngram")
body = json.loads(_flag_value(cmd, "--speculative-config"))
assert body["method"] == "ngram"
assert body["num_speculative_tokens"] >= 1
def test_ngram_draft_length_is_configurable():
cmd = _cmd(speculative="ngram", speculative_tokens=3)
assert json.loads(_flag_value(cmd, "--speculative-config"))[
"num_speculative_tokens"] == 3
def test_a_real_draft_model_can_be_configured_as_json():
raw = '{"method": "eagle", "model": "org/eagle-head", "num_speculative_tokens": 4}'
body = json.loads(_flag_value(_cmd(speculative=raw), "--speculative-config"))
assert body["model"] == "org/eagle-head"
def test_unparseable_speculative_config_does_not_stop_the_engine_booting():
"""An argparse error about JSON would name neither the model nor the field."""
assert "--speculative-config" not in _cmd(speculative="{not json")
def test_no_speculative_flag_by_default():
assert "--speculative-config" not in _cmd()
def test_blank_quantization_lets_vllm_detect_the_checkpoint():
"""Auto-detect picks the fastest kernel; a wrong name picks a slower one."""
assert "--quantization" not in _cmd()
assert _flag_value(_cmd(quantization="awq"), "--quantization") == "awq"
# --------------------------------------------------------------------------
# the settings travel: per-model override, and on to a pod
# --------------------------------------------------------------------------
def test_the_levers_are_overridable_per_model():
from codai.backends.overrides import VLLM_FIELDS, apply
from codai.config import VllmConfig
out = apply(VllmConfig(), {"max_num_batched_tokens": 8192, "prefix_caching": "on",
"speculative": "ngram", "speculative_tokens": 4,
"quantization": "awq"}, VLLM_FIELDS, "vllm")
assert out.max_num_batched_tokens == 8192
assert out.prefix_caching == "on"
assert out.speculative == "ngram"
assert out.speculative_tokens == 4
assert out.quantization == "awq"
def test_a_model_cannot_override_what_is_not_whitelisted():
from codai.backends.overrides import VLLM_FIELDS, apply
from codai.config import VllmConfig
out = apply(VllmConfig(), {"install_dir": "/somewhere/else"}, VLLM_FIELDS, "vllm")
assert not hasattr(out, "install_dir")
def test_the_levers_reach_a_pod():
"""They describe how to RUN the model, so they belong where it runs."""
from codai.api.runpod_worker import _ENGINE_FORWARDED_FIELDS
forwarded = _ENGINE_FORWARDED_FIELDS["vllm"]
for field in ("max_num_batched_tokens", "prefix_caching",
"speculative", "speculative_tokens", "quantization"):
assert field in forwarded, f"{field} would stay on the orchestrator"
def test_the_admin_save_route_accepts_the_levers():
src = (ROOT / "codai/admin/routes.py").read_text(encoding="utf-8")
block = re.search(r'for _blk, _fields in \(\("vllm",(.*?)\)\),', src, re.S)
assert block, "the per-model engine block whitelist moved"
for field in ("max_num_batched_tokens", "prefix_caching", "speculative"):
assert f'"{field}"' in block.group(1), f"{field} is dropped on save"
# --------------------------------------------------------------------------
# the save no longer wipes the block
# --------------------------------------------------------------------------
needs_node = pytest.mark.skipif(not shutil.which("node"), reason="node is not installed")
@pytest.fixture(scope="module")
def html():
return PAGE.read_text(encoding="utf-8")
def _fn(html, name):
m = re.search(r"^function %s\(.*?^}" % re.escape(name), html, re.S | re.M)
assert m, f"{name}() is gone from the page"
return m.group(0)
def _collect(html, tmp_path, backend, engine, fields=None):
"""Run the page's own _collectCluster() against a stubbed form."""
const = re.search(r"^const _VLLM_POD_ENGINES = .*?;", html, re.S | re.M)
assert const, "_VLLM_POD_ENGINES is gone from the page"
values = dict(fields or {})
values["cfg-backend"] = backend
values["cfg-rp-engine"] = engine
js = tmp_path / "collect.js"
js.write_text(
"const VALUES = %s;\n"
"function parse(){}\n"
"const document = { getElementById: id =>"
" ({ value: VALUES[id] === undefined ? '' : VALUES[id],"
" checked: !!VALUES[id] }) };\n"
% json.dumps(values)
+ const.group(0) + "\n"
+ _fn(html, "_vllmBlockApplies") + "\n"
+ _fn(html, "_nodesToLines") + "\n"
+ _fn(html, "_collectCluster") + "\n"
+ "console.log(JSON.stringify(_collectCluster()));",
encoding="utf-8")
proc = subprocess.run(["node", str(js)], capture_output=True, text=True)
assert proc.returncode == 0, proc.stderr.strip()[:800]
return json.loads(proc.stdout)
@needs_node
def test_saving_a_pod_served_model_does_not_delete_its_vllm_block(html, tmp_path):
"""The defect: an empty block is a destructive statement, not a no-op.
A model served by a vLLM pod has `backend: runpod`, so the card was hidden
and the collector emitted `vllm: {}` -- which the save route turns into
"delete the block". That silently dropped --kv-cache-dtype fp8 and the KV
cache floors, and nothing changed until the next pod booted in FP16.
"""
out = _collect(html, tmp_path, "runpod", "coderai-vllm",
{"cfg-vllm-extra": "--kv-cache-dtype fp8",
"cfg-vllm-kv-conc": "3"})
assert out.get("vllm"), "the vLLM block came back empty and would be deleted"
assert out["vllm"]["extra_args"] == "--kv-cache-dtype fp8"
assert out["vllm"]["kv_min_concurrency"] == 3
@needs_node
def test_a_block_whose_card_was_never_shown_is_left_alone(html, tmp_path):
"""Not sent at all, rather than sent empty: the route then keeps it."""
out = _collect(html, tmp_path, "cuda", "auto")
assert "vllm" not in out, "an unseen block must not be sent, even empty"
@needs_node
def test_the_levers_are_collected_from_the_page(html, tmp_path):
out = _collect(html, tmp_path, "runpod", "coderai-vllm",
{"cfg-vllm-mnbt": "8192", "cfg-vllm-prefix": "on",
"cfg-vllm-spec": "ngram", "cfg-vllm-spec-tokens": "5",
"cfg-vllm-quant": "awq"})
blk = out["vllm"]
assert blk["max_num_batched_tokens"] == 8192
assert blk["prefix_caching"] == "on"
assert blk["speculative"] == "ngram"
assert blk["speculative_tokens"] == 5
assert blk["quantization"] == "awq"
@needs_node
def test_a_local_vllm_model_still_gets_the_card(html, tmp_path):
out = _collect(html, tmp_path, "vllm", "auto", {"cfg-vllm-tp": "2"})
assert out["vllm"]["tensor_parallel_size"] == 2
@needs_node
def test_a_pod_on_another_engine_does_not_get_the_vllm_card(html, tmp_path):
"""A GGUF on llama.cpp has no vLLM settings to edit, so none are sent."""
out = _collect(html, tmp_path, "runpod", "coderai-llama")
assert "vllm" not in out
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment