compact an over-long conversation, against the window that is really there (v0.4.72)

Requests to a pod-served model were refused for exceeding the context window.
Auto-compaction has shipped for a while, but three things stood between it and
doing its job -- and one of them would have made enabling it worse than leaving
it off.

**The window was wrong.** A pod-served model's `ctx` lives in its runpod block
and becomes the pod's `--max-model-len`, but the backend applied the generic
`n_ctx` kwarg last -- and that kwarg carries the node-wide `vulkan.n_ctx`
fallback, default 2048, whenever the entry sets no `n_ctx` of its own. So a
model served with 32768 published 32768 on /v1/models (which reads the block)
and reported 2048 in every answer's `usage.context_size`. Compaction compacts to
a percentage of that number: switching it on would have crushed every
conversation to ~1300 tokens and looked like a model with no memory. The
block now wins, for pods and for vLLM, and a per-model `n_ctx` reaches the
launch so the reported window and the served one cannot disagree.

**The summary described the wrong messages.** It was built from "everything but
the last two turns", which includes the turns kept verbatim, so the kept head
and most of the kept tail were summarized AND kept -- paying twice in a window
that was already too small. The split is now decided once, and exactly the
dropped middle is summarized. The kept ends are configurable with it
(head_messages/head_tokens/tail_tokens/target_pct, globally and per model), the
head is reserved BEFORE the tail is filled (filling the tail first and adding
the head after overshoots the target by the whole head), and a head that would
end on an unanswered tool call is trimmed -- the mirror of the orphaned-result
rule the tail already had.

**The refusal was unreadable and final.** The numbers in it are the only exact
ones in the pipeline -- coderai estimates prompts at chars/4, the engine has
tokenized them -- and they were buried behind an apology, inside the pod's
wrapper, inside the orchestrator's, each prefixing its own status line and
120-character proxy URL. A client truncating the result got three layers of
prose and neither token count. Every layer now puts what it was told FIRST, the
message leads with `context_length_exceeded:` and both integers, and a refusal
is used rather than only reported: the real window is remembered for later
requests, the estimator is corrected by the ratio the engine just measured, and
the request is compacted against those numbers and sent once more
(compaction.retry_on_overflow, on by default).

Also fixes the layered fit, which mixed its units: the target was computed in
real tokens and handed to a splitter that measures in estimate tokens, so a
prompt the engine counts as too long could be judged already small enough and
left exactly as it was.
Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S8LAP3ueNzQJtwzhgVbq66
parent ef6657bd
......@@ -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.71"
__version__ = "0.4.72"
__tagline__ = "Complete orchestration, distribution and escalation of remotizable advanced inference."
# Configure the CUDA caching allocator BEFORE torch is imported anywhere.
......
......@@ -2935,7 +2935,11 @@ async def api_model_configure(request: Request, username: str = Depends(require_
"turboquant", "engine", "engine_fallback",
"quant_backend", "kv_cache_budget_mb", "kv_cache_slots", "mmproj",
"auto_compact", "auto_compact_pct", "auto_compact_strategy",
"auto_compact_model", "suppress_reasoning",
"auto_compact_model", "auto_compact_target_pct",
"auto_compact_head_messages", "auto_compact_head_tokens",
"auto_compact_tail_tokens", "auto_compact_tolerance_pct",
"auto_compact_min_output", "auto_compact_retry_on_overflow",
"suppress_reasoning",
# Speech-to-text (wav2vec2 / vosk / NeMo-Canary) options.
"languages", "supports_translation", "keep_resident",
# "local" pins the model here even when its whole capability is
......@@ -4284,6 +4288,11 @@ def build_settings_dict(c, gpu_cards):
"pct": c.compaction.pct,
"strategy": c.compaction.strategy,
"model": c.compaction.model,
"head_messages": c.compaction.head_messages,
"head_tokens": c.compaction.head_tokens,
"tail_tokens": c.compaction.tail_tokens,
"target_pct": c.compaction.target_pct,
"retry_on_overflow": c.compaction.retry_on_overflow,
},
"broker": {
"enabled": c.broker.enabled,
......@@ -5195,11 +5204,25 @@ async def api_save_settings(request: Request, username: str = Depends(require_ad
except (TypeError, ValueError):
pass
if "strategy" in cp:
_st = (cp.get("strategy") or "drop_oldest").strip()
if _st in ("drop_oldest", "keep_head_tail", "summarize"):
c.compaction.strategy = _st
# Accepted spellings live with the implementation, so a name the
# engine honours is never rejected here (and vice versa).
from codai.api.text import _COMPACT_STRATEGIES
_st = (cp.get("strategy") or "drop_oldest").strip().lower().replace("-", "_")
if _st in _COMPACT_STRATEGIES:
c.compaction.strategy = _COMPACT_STRATEGIES[_st]
if "model" in cp:
c.compaction.model = (cp.get("model") or "").strip()
# The kept ends. 0 is meaningful for all three (= "whatever fits"), so
# these are clamped at the bottom, not forced to a minimum.
for _k, _lo, _hi in (("head_messages", 0, 50), ("head_tokens", 0, 1_000_000),
("tail_tokens", 0, 1_000_000), ("target_pct", 10, 95)):
if _k in cp:
try:
setattr(c.compaction, _k, max(_lo, min(_hi, int(cp[_k]))))
except (TypeError, ValueError):
pass
if "retry_on_overflow" in cp:
c.compaction.retry_on_overflow = bool(cp["retry_on_overflow"])
if "broker" in data:
bro = data["broker"]
......
......@@ -1383,16 +1383,36 @@ window.__DEFAULT_WHISPER_SERVER_PATH__ = {{ default_whisper_server_path|tojson }
<div class="form-row" style="margin:0">
<label class="form-label">Trigger at <span class="muted">(% of context)</span></label>
<input type="number" id="cfg-autocompact-pct" class="form-input" min="50" max="99" step="1" value="85">
<span class="form-hint" style="font-size:11px">Compacts to ~65% of the context window when the prompt reaches this %.</span>
<span class="form-hint" style="font-size:11px">Compacts down to the size below when the prompt reaches this %.</span>
</div>
<div class="form-row" style="margin:0">
<label class="form-label">Strategy</label>
<select id="cfg-autocompact-strategy" class="form-input" onchange="_toggleAutoCompact()">
<option value="drop_oldest">Drop oldest — keep system + most recent turns</option>
<option value="keep_head_tail">Keep head + tail — also keep the first turn, drop the middle</option>
<option value="summarize">Summarize — replace the dropped middle with an LLM summary</option>
<option value="keep_head_tail">Keep head + tail — keep the opening turns and the recent ones, drop the middle</option>
<option value="summarize">Summarize the middle — keep both ends, replace the centre with an LLM summary</option>
</select>
</div>
<div class="form-row" style="margin:0">
<label class="form-label">Compact down to <span class="muted">(% of context)</span></label>
<input type="number" id="cfg-autocompact-target" class="form-input" min="10" max="95" step="1" value="65">
<span class="form-hint" style="font-size:11px">Lower leaves more room to grow before the next compaction.</span>
</div>
<div class="form-row" style="margin:0">
<label class="form-label">Keep at the start <span class="muted">(turns)</span></label>
<input type="number" id="cfg-autocompact-head-msgs" class="form-input" min="0" max="50" step="1" value="1">
<span class="form-hint" style="font-size:11px">From the first user turn. 0 = keep nothing at the start.</span>
</div>
<div class="form-row" style="margin:0">
<label class="form-label">Start cap <span class="muted">(tokens, 0 = auto)</span></label>
<input type="number" id="cfg-autocompact-head-tok" class="form-input" min="0" step="256" value="0">
<span class="form-hint" style="font-size:11px">0 = at most half the target, so the start never starves the recent end.</span>
</div>
<div class="form-row" style="margin:0">
<label class="form-label">Keep at the end <span class="muted">(tokens, 0 = the rest)</span></label>
<input type="number" id="cfg-autocompact-tail-tok" class="form-input" min="0" step="256" value="0">
<span class="form-hint" style="font-size:11px">Recent conversation kept verbatim.</span>
</div>
<div class="form-row" id="cfg-autocompact-model-row" style="margin:0;grid-column:1/-1;display:none">
<label class="form-label">Summarizer model <span class="muted">(summarize only)</span></label>
<select id="cfg-autocompact-model" class="form-input">
......@@ -4195,6 +4215,10 @@ function openCfgModal(idx, cfgIdx){
document.getElementById('cfg-autocompact').checked = !!s.auto_compact;
document.getElementById('cfg-autocompact-pct').value = s.auto_compact_pct != null ? s.auto_compact_pct : 85;
document.getElementById('cfg-autocompact-strategy').value = s.auto_compact_strategy || 'drop_oldest';
document.getElementById('cfg-autocompact-target').value = s.auto_compact_target_pct != null ? s.auto_compact_target_pct : 65;
document.getElementById('cfg-autocompact-head-msgs').value = s.auto_compact_head_messages != null ? s.auto_compact_head_messages : 1;
document.getElementById('cfg-autocompact-head-tok').value = s.auto_compact_head_tokens || 0;
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;
_toggleAutoCompact();
......@@ -5233,6 +5257,10 @@ async function saveModelConfig(){
auto_compact: document.getElementById('cfg-autocompact').checked,
auto_compact_pct: parseInt(document.getElementById('cfg-autocompact-pct').value) || 85,
auto_compact_strategy: document.getElementById('cfg-autocompact-strategy').value || 'drop_oldest',
auto_compact_target_pct: parseInt(document.getElementById('cfg-autocompact-target').value) || 65,
auto_compact_head_messages: parseInt(document.getElementById('cfg-autocompact-head-msgs').value) || 0,
auto_compact_head_tokens: parseInt(document.getElementById('cfg-autocompact-head-tok').value) || 0,
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,
max_gpu_percent: isNaN(maxGpu) ? null : maxGpu,
......
......@@ -1536,10 +1536,39 @@
<label class="form-label">Strategy</label>
<select id="s-compact-strategy" class="form-input">
<option value="drop_oldest">Drop oldest — keep system + most recent turns</option>
<option value="keep_head_tail">Keep head + tail — also keep the first turn, drop the middle</option>
<option value="summarize">Summarize — replace the dropped middle with an LLM summary</option>
<option value="keep_head_tail">Keep head + tail — keep the opening turns and the recent ones, drop the middle</option>
<option value="summarize">Summarize the middle — keep both ends, replace the centre with an LLM summary</option>
</select>
</div>
<div class="form-row">
<label class="form-label">Compact down to <span class="muted">(% of context)</span></label>
<input type="number" id="s-compact-target" class="form-input" min="10" max="95" step="1" placeholder="65">
<span class="form-hint">The size the compacted prompt aims for. Lower leaves more room for the conversation to grow again before the next compaction.</span>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:.75rem">
<div class="form-row">
<label class="form-label">Keep at the start <span class="muted">(turns)</span></label>
<input type="number" id="s-compact-head-msgs" class="form-input" min="0" max="50" step="1" placeholder="1">
<span class="form-hint">Opening turns kept verbatim, counted from the first user turn — the instructions a long conversation drifts away from. 0 = keep nothing at the start.</span>
</div>
<div class="form-row">
<label class="form-label">Start cap <span class="muted">(tokens, 0 = auto)</span></label>
<input type="number" id="s-compact-head-tok" class="form-input" min="0" step="256" placeholder="0">
<span class="form-hint">Upper bound on the kept start. 0 = at most half the target, so the start can never starve the recent end.</span>
</div>
<div class="form-row">
<label class="form-label">Keep at the end <span class="muted">(tokens, 0 = the rest)</span></label>
<input type="number" id="s-compact-tail-tok" class="form-input" min="0" step="256" placeholder="0">
<span class="form-hint">Recent conversation kept verbatim. 0 = everything the target still allows after the start.</span>
</div>
</div>
<div class="form-row">
<label style="display:flex;align-items:center;gap:.5rem;cursor:pointer">
<input type="checkbox" id="s-compact-retry">
<span style="font-size:13px;font-weight:500">Retry once when an engine refuses a request for length</span>
</label>
<span class="form-hint">A refusal states the real window and the real prompt size, which an estimate can both get wrong — a proxied model is launched elsewhere, and characters÷4 undercounts dense text. With this on, the request is compacted against those numbers and sent again instead of failing, and the corrected window is remembered for later requests.</span>
</div>
<div class="form-row">
<label class="form-label">Summarizer model <span class="muted">(summarize strategy)</span></label>
......@@ -2166,6 +2195,11 @@ async function loadSettings(){
document.getElementById('s-compact-pct').value = compact.pct ?? 85;
document.getElementById('s-compact-strategy').value = compact.strategy || 'drop_oldest';
document.getElementById('s-compact-model').value = compact.model ?? '';
document.getElementById('s-compact-target').value = compact.target_pct ?? 65;
document.getElementById('s-compact-head-msgs').value = compact.head_messages ?? 1;
document.getElementById('s-compact-head-tok').value = compact.head_tokens ?? 0;
document.getElementById('s-compact-tail-tok').value = compact.tail_tokens ?? 0;
document.getElementById('s-compact-retry').checked = compact.retry_on_overflow !== false;
toggleCompactFields();
// Thermal protection
const therm = d.thermal || {};
......@@ -2749,6 +2783,11 @@ async function saveSettings(silent){
pct: parseInt(document.getElementById('s-compact-pct').value) || 85,
strategy: document.getElementById('s-compact-strategy').value || 'drop_oldest',
model: document.getElementById('s-compact-model').value.trim(),
target_pct: parseInt(document.getElementById('s-compact-target').value) || 65,
head_messages: parseInt(document.getElementById('s-compact-head-msgs').value) || 0,
head_tokens: parseInt(document.getElementById('s-compact-head-tok').value) || 0,
tail_tokens: parseInt(document.getElementById('s-compact-tail-tok').value) || 0,
retry_on_overflow: document.getElementById('s-compact-retry').checked,
},
broker:{
enabled: document.getElementById('s-broker-enabled').checked,
......
# CoderAI - OpenAI-compatible API server
# Copyright (C) 2026 Stefy Lanza <stefy@nexlab.net>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
"""Reading a "does not fit the context window" refusal, and answering one.
Two jobs, both about the same two numbers -- the window, and what was asked of
it.
**Parsing.** Every engine words the refusal differently, and the numbers in it
are the only exact ones in the whole pipeline: coderai's own prompt size is a
chars/4 estimate, while the engine has tokenized the prompt. So when a request
is refused, the refusal is worth more than the estimate that let it through --
it says what the window really is (which can disagree with the configured
``ctx``) and exactly how far over the prompt went. :func:`parse` extracts both,
and :func:`remember_limit` keeps the window so the next request is compacted
against the served truth instead of the configured guess.
**Answering.** The numbers go FIRST in the message. This is not cosmetic. A
refusal crosses as many as three processes -- vLLM inside the pod, the pod's own
coderai, then this one -- and each wraps the one below it with its own prefix,
status line and URL. A client that reads a truncated prefix of the result (300,
400, 1000 characters: every client picks a cap, and the pod proxy URL alone can
be 120 characters) was getting the apologies and none of the figures, so it
could not tell an over-long conversation from any other 500 and had nothing to
retry with. Front-loading them means the first clause carries everything a
caller needs to shrink the request and try again:
context_length_exceeded: 35000 tokens requested, limit 32768 (34000 prompt
+ 1000 completion) for model 'qwen38' -- ...
A caller matching on ``context_length_exceeded`` or reading the two integers
sees them whatever the cap, and the OpenAI-style ``code`` is the same string, so
matching on either works.
"""
from __future__ import annotations
import re
import threading
from typing import Dict, Optional
#: The OpenAI error code for this condition, and the token clients match on.
CODE = "context_length_exceeded"
#: Phrasings seen in the wild, each yielding what it knows. Order matters only
#: in that the first match wins, so the most specific patterns come first.
_PATTERNS = (
# vLLM / OpenAI: the richest phrasing, and the one a pod answers with.
re.compile(
r"maximum context length is (?P<limit>\d+) tokens.{0,40}?"
r"you requested (?P<requested>\d+) tokens?"
r"(?:\D{0,40}?(?P<prompt>\d+) in the messages"
r"\D{0,40}?(?P<completion>\d+) in the completion)?",
re.IGNORECASE | re.DOTALL),
# vLLM V1 engine, prompt-only phrasing.
re.compile(
r"(?:decoder )?prompt \(length (?P<prompt>\d+)\) is longer than the "
r"maximum model length of (?P<limit>\d+)", re.IGNORECASE),
# llama-cpp-python.
re.compile(
r"requested tokens \((?P<requested>\d+)\) exceed context window of "
r"(?P<limit>\d+)", re.IGNORECASE),
# coderai's own pre-flight refusal (see codai.api.text), so a relayed
# message from a downstream coderai parses as well as an engine's.
re.compile(
r"(?P<requested>\d+) tokens requested, limit (?P<limit>\d+)",
re.IGNORECASE),
re.compile(
r"~?(?P<prompt>\d+) prompt tokens vs n_ctx=(?P<limit>\d+)",
re.IGNORECASE),
)
#: Text that means "it did not fit" even when no pattern yields numbers. Kept
#: deliberately broad: treating an overflow as an ordinary 500 costs a retry
#: that would have worked, while the reverse costs one pointless compaction.
_MARKERS = (
"maximum context length", "context length", "context window",
"context size", "available context", "exceed context", "exceeds n_ctx",
"n_ctx", "maximum model length",
"kv cache is full", "context shift is disabled", "too many tokens",
"reduce the length of the messages", CODE,
)
#: ``{model: limit}`` learned from refusals. Process-local on purpose: it is a
#: correction to a configured number, not a configuration, and it must not
#: outlive a pod whose window really did change.
_LEARNED: Dict[str, int] = {}
_LOCK = threading.Lock()
def parse(text) -> Optional[dict]:
"""``{limit, requested, prompt, completion}`` from a refusal, or None.
Only the keys the message actually stated are present, except that
``requested`` is derived from prompt+completion (or prompt alone) when the
engine reported the parts but not the total -- a caller comparing "asked"
against "allowed" should not have to know which phrasing it got.
"""
s = str(text or "")
if not s:
return None
for pat in _PATTERNS:
m = pat.search(s)
if not m:
continue
got = {}
for key, raw in (m.groupdict() or {}).items():
if raw is None:
continue
try:
got[key] = int(raw)
except (TypeError, ValueError):
continue
if "requested" not in got:
if "prompt" in got:
got["requested"] = got["prompt"] + got.get("completion", 0)
if got.get("limit") or got.get("requested"):
return got
return None
def is_overflow(text) -> bool:
"""Whether this text is an engine refusing a request for being too long."""
low = str(text or "").lower()
if not low:
return False
if any(m in low for m in _MARKERS):
return True
# "exceeds the model's 32768 token limit" and similar: a token count and an
# exceedance, which together are not said about anything else.
return "token" in low and ("exceed" in low or "too long" in low)
def remember_limit(model: str, limit) -> Optional[int]:
"""Record the window an engine says it has, and return it.
The served window and the configured one disagree in practice, and only the
served one can refuse a request. Learning it from the refusal costs nothing
-- no probe, no extra request -- and it is what makes a retry land inside
the window rather than one estimate closer to it.
"""
try:
n = int(limit or 0)
except (TypeError, ValueError):
return None
name = str(model or "").strip()
if not name or n <= 0:
return None
with _LOCK:
_LEARNED[name] = n
return n
def learned_limit(model: str) -> Optional[int]:
with _LOCK:
return _LEARNED.get(str(model or "").strip())
def learn_from(model: str, text) -> Optional[dict]:
"""Parse a refusal and remember the limit it states. Returns the parse."""
info = parse(text)
if info and info.get("limit"):
remember_limit(model, info["limit"])
return info
def forget(model: Optional[str] = None) -> None:
with _LOCK:
if model is None:
_LEARNED.clear()
else:
_LEARNED.pop(str(model or "").strip(), None)
def message(info: Optional[dict] = None, model: str = "",
compacted: bool = False, raw: str = "") -> str:
"""The client-facing refusal, numbers first. See the module docstring.
``raw`` (the engine's own words) is appended rather than replaced: it can
name a cause the numbers do not, and by then it is past the figures that a
truncating client needs.
"""
info = info or {}
limit = info.get("limit")
requested = info.get("requested")
bits = []
if requested and limit:
bits.append(f"{requested} tokens requested, limit {limit}")
elif limit:
bits.append(f"over the {limit}-token limit")
elif requested:
bits.append(f"{requested} tokens requested")
else:
bits.append("the request is longer than the context window")
parts = []
if info.get("prompt"):
parts.append(f"{info['prompt']} prompt")
if info.get("completion"):
parts.append(f"{info['completion']} completion")
if parts:
bits.append("(" + " + ".join(parts) + ")")
if model:
bits.append(f"for model '{model}'")
head = f"{CODE}: " + " ".join(bits)
tail = ("even after auto-compaction. Shorten the conversation, lower "
"max_tokens, or raise the model's context size."
if compacted else
"The conversation is too long for this model's context window. "
"Shorten it, lower max_tokens, enable auto-compaction for this "
"model, or raise its context size.")
out = f"{head} — {tail}"
raw = str(raw or "").strip()
if raw and raw not in out:
out += f" (engine said: {raw})"
return out
......@@ -456,26 +456,134 @@ def _estimate_tokens(messages) -> int:
return int(total / 4) + 8
def _compact_messages(messages, n_ctx, pct, strategy, summary_text=None, target=None):
"""Shrink an over-long message list to fit ``target`` tokens (default ~65% of
n_ctx), keeping system messages and the most recent turns. Returns
(new_messages, info|None). Strategies:
#: Strategy spellings accepted in config, mapped to the three implemented ones.
#: An unknown strategy must not silently become drop_oldest: an operator who
#: asked to keep the head and tail and got the head thrown away would see a
#: model that has forgotten its instructions and no sign of why.
_COMPACT_STRATEGIES = {
"drop_oldest": "drop_oldest",
"keep_head_tail": "keep_head_tail",
"head_tail": "keep_head_tail",
"summarize": "summarize",
"summarize_middle": "summarize", # how the behaviour is usually described
"summarise": "summarize",
}
def _compact_strategy(name) -> str:
"""The implemented strategy for a configured name (default keep_head_tail)."""
key = str(name or "").strip().lower().replace("-", "_")
return _COMPACT_STRATEGIES.get(key, "keep_head_tail" if key else "drop_oldest")
def _split_for_compaction(messages, target, strategy, head_messages=1,
head_tokens=0, tail_tokens=0):
"""``(system, head, middle, tail)`` for a history that must fit ``target``.
The one place that decides what survives, so the summarizer and the
assembler cannot disagree about it. They used to: the summary was built from
"everything but the last two turns", which included the very messages kept
verbatim, so the kept head and most of the kept tail were ALSO described in
the summary above them -- paying twice for the same words in a window that
was already too small, and inviting the model to treat a paraphrase of the
live conversation as earlier history. ``middle`` is exactly what is about to
be lost, and exactly what is worth summarizing.
Budget order matters too. The head is reserved BEFORE the tail is filled:
filling the tail first and adding the head afterwards (as this did) can
overshoot ``target`` by the whole head, which with a configurable head is
the difference between fitting and being refused.
"""
sys_msgs = [m for m in messages if m.get("role") == "system"]
body = [m for m in messages if m.get("role") != "system"]
budget = max(1, int(target) - _estimate_tokens(sys_msgs))
if not body:
return sys_msgs, [], [], []
head = []
if strategy in ("keep_head_tail", "summarize") and len(body) > 1:
want = 1 if head_messages is None else max(0, int(head_messages))
# The opening instructions are the anchor, so the head starts at the
# first USER turn: a leading assistant greeting anchors nothing.
start = next((i for i, m in enumerate(body)
if m.get("role") == "user"), None)
# Never more than half the budget on history that is NOT recent, unless
# an operator asked for a specific head size in tokens.
cap = int(head_tokens or 0) or max(1, int(budget * 0.5))
used = 0
if start is not None:
for m in body[start:-1]: # never the current turn
if len(head) >= want:
break
t = _estimate_tokens([m])
if head and used + t > cap:
break
head.append(m)
used += t
# A head ending on an assistant's tool_calls would have its tool results
# dropped with the middle, and a chat template raises on that pair --
# the mirror of the orphaned-result rule applied to the tail below.
while head and head[-1].get("tool_calls"):
head.pop()
head = [m for m in head if m.get("role") != "tool"] if head and head[0].get("role") == "tool" else head
# Identity, not equality: message dicts can be value-equal (duplicate "try
# again" turns, identical tool results).
head_ids = {id(m) for m in head}
used = _estimate_tokens(head) if head else 0
tail_budget = int(tail_tokens or 0) or (budget - used)
tail_budget = max(1, min(tail_budget, max(1, budget - used)))
tail, tail_ids, running = [], set(), 0
for m in reversed(body):
if id(m) in head_ids:
break
t = _estimate_tokens([m])
if tail and running + t > tail_budget:
break
tail.insert(0, m)
tail_ids.add(id(m))
running += t
# Don't start the kept tail on an orphaned tool result (its assistant
# tool_calls turn would have been dropped) — that breaks chat templates.
while tail and tail[0].get("role") == "tool":
running -= _estimate_tokens([tail[0]])
tail_ids.discard(id(tail[0]))
tail.pop(0)
middle = [m for m in body
if id(m) not in head_ids and id(m) not in tail_ids]
return sys_msgs, head, middle, tail
def _compact_messages(messages, n_ctx, pct, strategy, summary_text=None, target=None,
head_messages=1, head_tokens=0, tail_tokens=0,
target_pct=65):
"""Shrink an over-long message list to fit ``target`` tokens (default
``target_pct``% of n_ctx), keeping system messages and the most recent turns.
Returns (new_messages, info|None). Strategies:
- drop_oldest : keep only system + the recent tail that fits.
- keep_head_tail: also keep the first user turn (context anchor) + a note.
- keep_head_tail: also keep the opening turn(s) (context anchor) + a note.
- summarize : keep_head_tail, but replace the dropped middle with an LLM
summary (``summary_text``) when available, else a count note.
Returns info=None (no change) when not over threshold or nothing can be dropped.
``head_messages``/``head_tokens``/``tail_tokens`` size the kept ends; 0 means
"whatever fits". Returns info=None (no change) when not over threshold or
nothing can be dropped.
"""
if not messages or not n_ctx or n_ctx <= 0:
return messages, None
strategy = _compact_strategy(strategy)
try:
pct = float(pct)
except (TypeError, ValueError):
pct = 85.0
pct = min(99.0, max(50.0, pct))
try:
target_pct = min(95.0, max(10.0, float(target_pct or 65)))
except (TypeError, ValueError):
target_pct = 65.0
est = _estimate_tokens(messages)
if target is None:
target = int(n_ctx * 0.65)
target = int(n_ctx * target_pct / 100.0)
if est < n_ctx * pct / 100.0:
return messages, None
else:
......@@ -483,37 +591,8 @@ def _compact_messages(messages, n_ctx, pct, strategy, summary_text=None, target=
if est <= target:
return messages, None
sys_msgs = [m for m in messages if m.get("role") == "system"]
body = [m for m in messages if m.get("role") != "system"]
running = _estimate_tokens(sys_msgs)
# Track membership by object identity — message dicts can be value-equal
# (e.g. duplicate "try again" turns or identical tool results).
tail = []
tail_ids = set()
for m in reversed(body):
t = _estimate_tokens([m])
if tail and running + t > target:
break
tail.insert(0, m)
tail_ids.add(id(m))
running += t
# Don't start the kept tail on an orphaned tool result (its assistant
# tool_calls turn would have been dropped) — that breaks chat templates.
while tail and tail[0].get("role") == "tool":
running -= _estimate_tokens([tail[0]])
tail_ids.discard(id(tail[0]))
tail.pop(0)
head = []
head_ids = set()
if strategy in ("keep_head_tail", "summarize"):
first_user = next((m for m in body if m.get("role") == "user"), None)
if first_user is not None and id(first_user) not in tail_ids:
head = [first_user]
head_ids.add(id(first_user))
dropped = [m for m in body if id(m) not in head_ids and id(m) not in tail_ids]
sys_msgs, head, dropped, tail = _split_for_compaction(
messages, target, strategy, head_messages, head_tokens, tail_tokens)
if not dropped:
return messages, None
......@@ -533,8 +612,9 @@ def _compact_messages(messages, n_ctx, pct, strategy, summary_text=None, target=
if last_msg is not None and (not new or id(new[-1]) != id(last_msg)):
new = [m for m in new if id(m) != id(last_msg)] + [last_msg]
return new, {"dropped": len(dropped), "strategy": strategy,
"kept_head": len(head), "kept_tail": len(tail),
"before_tokens": est, "after_tokens": _estimate_tokens(new),
"n_ctx": n_ctx}
"target": target, "n_ctx": n_ctx}
async def _summarize_one(manager, text: str, max_tokens: int = 400):
......@@ -558,14 +638,15 @@ def _summary_chunk_chars(compact_n_ctx: int) -> int:
return max(2000, int(usable * 4 * 0.75))
async def _summarize_for_compact(manager, messages, keep_recent: int = 2,
compact_n_ctx: int = 8192, progress=None):
"""Best-effort map-reduce summary of the older turns using ``manager`` (which
may be a DIFFERENT model than the one serving the request): CHUNK the history
to fit ``compact_n_ctx``, summarize each chunk, then iteratively reduce the
combined chunk summaries until they fit one chunk. ``progress`` is an optional
async callable(str) used to stream status to the client. Returns a summary
string or None (caller falls back to a count note)."""
async def _summarize_for_compact(manager, middle, compact_n_ctx: int = 8192,
progress=None):
"""Best-effort map-reduce summary of ``middle`` -- the messages that are about
to be dropped, and only those (see _split_for_compaction) -- using ``manager``,
which may be a DIFFERENT model than the one serving the request: CHUNK the
history to fit ``compact_n_ctx``, summarize each chunk, then iteratively
reduce the combined chunk summaries until they fit one chunk. ``progress`` is
an optional async callable(str) used to stream status to the client. Returns a
summary string or None (caller falls back to a count note)."""
async def _emit(msg):
if progress:
try:
......@@ -573,8 +654,7 @@ async def _summarize_for_compact(manager, messages, keep_recent: int = 2,
except Exception:
pass
try:
body = [m for m in messages if m.get("role") != "system"]
older = body[:-keep_recent] if len(body) > keep_recent else body
older = [m for m in (middle or []) if m.get("role") != "system"]
if not older:
return None
lines = []
......@@ -856,6 +936,12 @@ def _resolve_compaction(request, current_manager):
except (TypeError, ValueError):
tol_pct = 20.0
min_output = _mc("auto_compact_min_output", _gv("min_output", 512)) or 512
head_messages = _mc("auto_compact_head_messages", _gv("head_messages", 1))
head_tokens = _mc("auto_compact_head_tokens", _gv("head_tokens", 0))
tail_tokens = _mc("auto_compact_tail_tokens", _gv("tail_tokens", 0))
target_pct = _mc("auto_compact_target_pct", _gv("target_pct", 65))
retry_on_overflow = bool(_mc("auto_compact_retry_on_overflow",
_gv("retry_on_overflow", True)))
try:
safety = max(1.0, float(_mc("auto_compact_estimate_safety", _gv("estimate_safety", 1.15))))
except (TypeError, ValueError):
......@@ -865,18 +951,43 @@ def _resolve_compaction(request, current_manager):
n_ctx = current_manager.get_context_size() if current_manager else 0
except Exception:
n_ctx = 0
# A window an engine has already refused a request against beats the
# configured one, which is a hope: a proxied model's `ctx` is what coderai
# was told to ask for, not necessarily what the pod was launched with, and
# only the served number can refuse anything. Smaller wins, because
# compacting to a window larger than the real one is the overflow we are
# here to prevent.
try:
from codai.api import context_overflow as _co
_served = _co.learned_limit(getattr(request, "model", None) or "")
if _served and (not n_ctx or _served < n_ctx):
n_ctx = int(_served)
except Exception:
pass
# NOTE: the summarizer model (``compact_model``) is resolved LAZILY in
# _auto_compact_events, only when the prompt is actually over threshold — so a
# configured separate model isn't loaded on every (under-threshold) request.
# ``max_tokens`` (the reply reservation) is counted against the window too —
# the model writes its reply into the same n_ctx as the prompt.
def _int(v, default):
try:
return int(v)
except (TypeError, ValueError):
return default
return {
"pct": float(pct), "strategy": strategy, "n_ctx": n_ctx,
"compact_model": compact_model, "current_manager": current_manager,
"model": getattr(request, "model", None) or "",
"max_tokens": getattr(request, "max_tokens", None),
"tolerance": 1.0 + tol_pct / 100.0, "min_output": int(min_output),
"safety": safety,
"head_messages": max(0, _int(head_messages, 1)),
"head_tokens": max(0, _int(head_tokens, 0)),
"tail_tokens": max(0, _int(tail_tokens, 0)),
"target_pct": float(_int(target_pct, 65) or 65),
"retry_on_overflow": retry_on_overflow,
}
......@@ -916,7 +1027,11 @@ async def _auto_compact_events(plan, messages):
None)."""
n_ctx = plan["n_ctx"]
pct = plan["pct"]
strategy = plan["strategy"]
strategy = _compact_strategy(plan["strategy"])
head_messages = plan.get("head_messages", 1)
head_tokens = int(plan.get("head_tokens") or 0)
tail_tokens = int(plan.get("tail_tokens") or 0)
target_pct = float(plan.get("target_pct") or 65)
mt = int(plan.get("max_tokens") or 0)
tol = float(plan.get("tolerance") or 1.20)
min_out = int(plan.get("min_output") or 512)
......@@ -937,6 +1052,16 @@ async def _auto_compact_events(plan, messages):
# prompts, and trimming to the exact n_ctx edge off an undercount still
# overflows the backend.
prompt_est = _estimate_tokens(messages)
# When an engine has already refused this prompt it reported how many tokens
# it really held, and that measurement replaces the guess: chars/4 undercounts
# token-dense text badly enough that a prompt can be refused while the
# estimate says it fits, which would make a retry an identical request. The
# ratio between what the engine counted and what we estimated is the
# estimator's error for THIS conversation, so apply it rather than nudging
# the safety factor and hoping.
measured = int(plan.get("measured_prompt_tokens") or 0)
if measured and prompt_est > 0:
safety = max(safety, (measured / float(prompt_est)) * 1.02)
pest = int(prompt_est * safety)
if pest + mt <= n_ctx:
yield ("done", messages, None, None)
......@@ -947,7 +1072,14 @@ async def _auto_compact_events(plan, messages):
# window so the prompt isn't squeezed to nothing); the kept prompt targets the
# rest, never more than ~65% of n_ctx.
reserve = max(min_out, min(mt or min_out, int(n_ctx * 0.5)))
target = min(int(n_ctx * 0.65), max(min_out, n_ctx - reserve))
target = min(int(n_ctx * target_pct / 100.0), max(min_out, n_ctx - reserve))
# ``target`` is in real tokens; the splitter and the slicer measure in
# ESTIMATE tokens (chars/4). Handing them the real target asks them to
# compact to a size they measure differently -- by exactly the factor the
# estimate is wrong by -- so a prompt the engine counts as too long can be
# judged already small enough and left as it is. Converting once here keeps
# every layer in the units it actually works in.
est_target = max(1, int(target / safety))
info = None
compact_manager = compact_n_ctx = None
......@@ -956,7 +1088,12 @@ async def _auto_compact_events(plan, messages):
# huge max_tokens is the problem, layer 2's trim handles it).
if pest + mt > tol_ctx and pest > target:
summary = None
if strategy == "summarize":
# What is about to be dropped, decided ONCE, so the summarizer describes
# the middle rather than the whole conversation (head and tail included,
# which then appeared both verbatim and paraphrased).
_, _head, _middle, _ = _split_for_compaction(
messages, est_target, strategy, head_messages, head_tokens, tail_tokens)
if strategy == "summarize" and _middle:
compact_manager, compact_name, compact_n_ctx = _resolve_compact_manager(plan)
via = f" via {compact_name}" if compact_name and compact_name != "the model" else ""
yield ("status", f"🗜 Compacting context (~{prompt_est}+{mt} tok > {int(tol * 100)}% "
......@@ -973,7 +1110,7 @@ async def _auto_compact_events(plan, messages):
async def _run():
try:
return await _summarize_for_compact(
compact_manager, messages,
compact_manager, _middle,
compact_n_ctx=compact_n_ctx, progress=_cb)
finally:
await _q.put(_DONE)
......@@ -988,15 +1125,19 @@ async def _auto_compact_events(plan, messages):
else:
yield ("status", f"🗜 Compacting context (~{prompt_est}+{mt} tok > {int(tol * 100)}% "
f"of {n_ctx}) using '{strategy}'…\n")
messages, info = _compact_messages(messages, n_ctx, pct, strategy, summary, target=target)
messages, info = _compact_messages(
messages, n_ctx, pct, strategy, summary, target=est_target,
head_messages=head_messages, head_tokens=head_tokens,
tail_tokens=tail_tokens, target_pct=target_pct)
if info:
yield ("status", f"✅ Context compacted: dropped {info['dropped']} message(s), "
f"kept {info['kept_head']} at the start + {info['kept_tail']} at the end, "
f"~{info['before_tokens']}→{info['after_tokens']} tokens.\n")
prompt_est = _estimate_tokens(messages)
pest = int(prompt_est * safety)
# Layer 4 — a single message is still bigger than the target; slice it.
if pest > target:
if prompt_est > est_target:
if compact_manager is None:
compact_manager, _cn, compact_n_ctx = _resolve_compact_manager(plan)
yield ("status", "✂ A single message exceeds the target; slicing it…\n")
......@@ -1009,7 +1150,8 @@ async def _auto_compact_events(plan, messages):
async def _run2():
try:
return await _slice_oversized(
compact_manager, messages, target, compact_n_ctx, progress=_cb2)
compact_manager, messages, est_target, compact_n_ctx,
progress=_cb2)
finally:
await _q2.put(_DONE2)
......@@ -1033,13 +1175,60 @@ async def _auto_compact_events(plan, messages):
# Final guard: even a minimal reply won't fit — the prompt alone is too big.
err = None
if pest + min_out > n_ctx:
err = ("The request is too large for this model's context window "
f"(~{prompt_est} prompt tokens vs n_ctx={n_ctx}) even after "
"auto-compaction. Shorten the latest message or increase the "
"model's context size (n_ctx).")
# Numbers first: this message is wrapped by every layer above, and a
# client that truncates the result must still be able to read them.
from codai.api import context_overflow as _co
err = _co.message({"limit": n_ctx, "requested": pest + min_out,
"prompt": prompt_est, "completion": min_out},
model=plan.get("model") or "", compacted=True)
yield ("done", messages, info, err)
async def _compact_after_refusal(plan, messages, model, detail):
"""Compact again using the numbers an engine just refused us with.
A pre-flight estimate can be wrong in two ways at once: the window may not be
the configured one (a proxied model is launched elsewhere, and only the
server that launched it knows ``--max-model-len``), and chars/4 undercounts
token-dense prompts. Both are measured exactly in the refusal, so the retry
is planned from the engine's own figures instead of the guess that failed.
Returns ``(messages, effective_max_tokens, info)`` for one retry, or None
when the refusal taught us nothing new -- in which case retrying would send
an identical request, and the caller should surface the error instead.
"""
from codai.api import context_overflow as _co
info = _co.parse(detail)
if not info or not info.get("limit"):
return None
limit = int(info["limit"])
_co.remember_limit(model, limit)
measured = int(info.get("prompt") or 0)
if not measured and info.get("requested"):
measured = int(info["requested"])
plan2 = dict(plan)
plan2["n_ctx"] = limit
plan2["measured_prompt_tokens"] = measured
# No tolerance on a retry. The tolerance exists to avoid compacting a
# conversation that is only slightly over an ESTIMATED limit, where trimming
# the reply is enough; here the engine has already refused the real request,
# so "slightly over" is simply over.
plan2["tolerance"] = 1.0
plan2.pop("effective_max_tokens", None)
out, eff, cinfo, cerr = messages, None, None, None
async for _ev in _auto_compact_events(plan2, list(messages)):
if _ev[0] == "status":
print(f"[auto-compact] retry: {_ev[1].strip()}", flush=True)
else:
_, out, cinfo, cerr = _ev
if cerr:
return None
eff = plan2.get("effective_max_tokens")
if cinfo is None and eff is None:
return None # nothing changed; don't re-send it
return out, eff, cinfo
@router.post("/v1/chat/completions", summary="Chat completions")
async def chat_completions(request: ChatCompletionRequest, http_request: Request = None):
"""Chat completions endpoint with streaming and tool support."""
......@@ -2341,28 +2530,58 @@ async def chat_completions(request: ChatCompletionRequest, http_request: Request
return StreamingResponse(_managed_stream(), media_type="text/event-stream")
else:
try:
return await generate_chat_response(
messages_dict,
response_model_name,
request.max_tokens,
request.temperature,
request.top_p,
stop_sequences,
tools_dict,
current_manager,
tool_parser,
request.response_format,
force_reasoning_args,
enable_thinking=reasoning_enabled,
suppress_reasoning=_suppress_reasoning,
reasoning_active=_reasoning_active,
repeat_penalty=request.repeat_penalty,
presence_penalty=request.presence_penalty,
frequency_penalty=request.frequency_penalty,
logprobs=request.logprobs,
top_logprobs=request.top_logprobs,
passthrough=_chat_passthrough(request),
)
async def _generate(_msgs, _max_tokens):
return await generate_chat_response(
_msgs,
response_model_name,
_max_tokens,
request.temperature,
request.top_p,
stop_sequences,
tools_dict,
current_manager,
tool_parser,
request.response_format,
force_reasoning_args,
enable_thinking=reasoning_enabled,
suppress_reasoning=_suppress_reasoning,
reasoning_active=_reasoning_active,
repeat_penalty=request.repeat_penalty,
presence_penalty=request.presence_penalty,
frequency_penalty=request.frequency_penalty,
logprobs=request.logprobs,
top_logprobs=request.top_logprobs,
passthrough=_chat_passthrough(request),
)
try:
return await _generate(messages_dict, request.max_tokens)
except HTTPException as _he:
# The engine refused the prompt for its length. Its refusal
# carries the two numbers the pre-flight estimate got wrong --
# the real window and the real prompt size -- so compact against
# those and send it once more. Without this a caller sees an
# error for a conversation coderai was configured to be able to
# shrink, and the next request repeats it.
from codai.api import context_overflow as _co
if not (_compact_plan and _compact_plan.get("retry_on_overflow")
and _he.status_code == 400 and _co.is_overflow(_he.detail)):
raise
_retry = await _compact_after_refusal(
_compact_plan, messages_dict,
getattr(request, "model", "") or response_model_name,
_he.detail)
if not _retry:
raise
_msgs2, _eff2, _info2 = _retry
print(f"[auto-compact] {getattr(request, 'model', '?')}: refused for "
f"length; retrying once after compaction"
+ (f" (dropped {_info2['dropped']} msgs via {_info2['strategy']}, "
f"~{_info2['before_tokens']}→{_info2['after_tokens']} tok)"
if _info2 else "")
+ (f", max_tokens→{_eff2}" if _eff2 is not None else ""), flush=True)
return await _generate(
_msgs2, _eff2 if _eff2 is not None else request.max_tokens)
finally:
_release_instance()
......@@ -2555,19 +2774,22 @@ def _gate_tool_content(buffer: str, final: bool = False,
return ''.join(emit), ''
def _context_overflow_detail(e) -> Optional[str]:
"""If the exception is a context-window overflow (prompt + generation exceed
the model's n_ctx), return a clear client-facing message; else None."""
s = str(e)
low = s.lower()
markers = ("exceed context window", "context window of", "requested tokens",
"exceeds n_ctx", "exceed the context", "context length",
"n_ctx", "kv cache is full", "context shift is disabled")
if any(m in low for m in markers) or ("token" in low and "exceed" in low):
return ("The conversation is too long for this model's context window "
f"({s}). Shorten the prompt or lower max_tokens, or increase the "
"model's context size (n_ctx) in its configuration.")
return None
def _context_overflow_detail(e, model: str = "", compacted: bool = False) -> Optional[str]:
"""If the exception is a context-window overflow, the client-facing message
for it -- with the numbers FIRST; else None.
The numbers lead because this message is wrapped on its way out: the engine
inside a pod answers the pod's own coderai, which answers this one, and each
layer prefixes its own status line and URL. A client that reads a truncated
prefix was getting three apologies and no figures. See
codai.api.context_overflow.
"""
from codai.api import context_overflow as _co
raw = str(e)
if not _co.is_overflow(raw):
return None
info = _co.learn_from(model, raw)
return _co.message(info, model=model, compacted=compacted, raw=raw)
def _detect_runaway_repetition(text: str) -> bool:
......@@ -3151,7 +3373,7 @@ async def stream_chat_response(
print(f"Error during streaming generation: {e}")
# Surface errors as a STRUCTURED error event (not as assistant content) so
# the client treats it as an error and it doesn't pollute the chat history.
_ctx = _context_overflow_detail(e)
_ctx = _context_overflow_detail(e, model_name)
if _ctx:
err = {"error": {"message": _ctx, "type": "invalid_request_error",
"code": "context_length_exceeded", "param": "messages"}}
......@@ -3432,7 +3654,7 @@ async def generate_chat_response(
return formatted_response
except Exception as e:
print(f"Error during generation: {e}")
_ctx = _context_overflow_detail(e)
_ctx = _context_overflow_detail(e, model_name)
if _ctx:
raise HTTPException(status_code=400, detail=_ctx)
raise HTTPException(status_code=500, detail=f"Generation error: {str(e)}")
......@@ -3659,7 +3881,7 @@ async def generate_completion_response(
}
except Exception as e:
print(f"Error during completion: {e}")
_ctx = _context_overflow_detail(e)
_ctx = _context_overflow_detail(e, model_name)
if _ctx:
raise HTTPException(status_code=400, detail=_ctx)
raise HTTPException(status_code=500, detail=f"Generation error: {str(e)}")
......@@ -32,6 +32,14 @@ class RemoteModelUnknown(RuntimeError):
"""The pod answered that it does not know the model we asked for."""
#: How much of a pod's explanation is kept. A pod's answer is itself a stack of
#: wrapped messages -- vLLM's words inside the pod's coderai inside its HTTP
#: error -- so 400 characters could end mid-prefix: a request refused for being
#: too long arrived with the apology and WITHOUT either of the two token counts
#: a caller needs to shrink it and retry, which read as an ordinary 500.
_DETAIL_CHARS = 1200
def _pod_detail(r) -> str:
"""What the pod SAID, not what requests says about the status line.
......@@ -43,15 +51,15 @@ def _pod_detail(r) -> str:
try:
body = r.json()
except Exception:
return (getattr(r, "text", "") or "").strip()[:400]
return (getattr(r, "text", "") or "").strip()[:_DETAIL_CHARS]
if isinstance(body, dict):
err = body.get("error")
if isinstance(err, dict) and err.get("message"):
return str(err["message"])[:400]
return str(err["message"])[:_DETAIL_CHARS]
for key in ("detail", "message", "error"):
if body.get(key):
return str(body[key])[:400]
return str(body)[:400]
return str(body[key])[:_DETAIL_CHARS]
return str(body)[:_DETAIL_CHARS]
class RunpodBackend(ModelBackend):
......@@ -121,8 +129,17 @@ class RunpodBackend(ModelBackend):
self._mcfg = runpod_worker.parse_model_runpod(block)
# served name: explicit served_model wins, else the model's own name.
self._served_name = self._mcfg.served_model or model_name or "runpod"
if self._mcfg.ctx:
self._ctx = self._mcfg.ctx
# The pod's own `ctx` wins over the generic kwarg, and that order is not
# a preference: `ctx` in the model's runpod block is what the pod is
# LAUNCHED with (`--max-model-len`), while the kwarg can be a global
# fallback no one chose for this model -- the node-wide `vulkan.n_ctx`,
# whose default is 2048. Taking the kwarg first made every pod-served
# model on an install without a per-model `n_ctx` report a 2048-token
# window while its pod served 32768: measured on a production install
# 2026-10-22, where `usage.context_size` said 2048, `/v1/models` said
# 32768 (it reads the runpod block), and auto-compaction, had it been
# enabled, would have crushed every conversation to ~1300 tokens to fit
# a window that was sixteen times larger.
_ctx = kwargs.get("n_ctx", kwargs.get("ctx"))
if isinstance(_ctx, (list, tuple)):
_ctx = _ctx[0] if _ctx else None
......@@ -130,7 +147,9 @@ class RunpodBackend(ModelBackend):
_ctx = int(_ctx) if _ctx else 0
except (TypeError, ValueError):
_ctx = 0
if _ctx > 0:
if self._mcfg.ctx:
self._ctx = self._mcfg.ctx
elif _ctx > 0:
self._ctx = _ctx
self._mode = (self._mcfg.mode or "pods").lower()
......@@ -342,8 +361,13 @@ class RunpodBackend(ModelBackend):
detail = _pod_detail(r)
if _is_unknown_model(r.status_code, (detail or "").encode("utf-8", "replace")):
raise RemoteModelUnknown(detail)
# The pod's words first, its status code after: this message is wrapped
# once more before a client sees it, and the figures in it (which window,
# how many tokens) are what the client needs from a prefix.
raise requests.HTTPError(
f"the pod answered {r.status_code}: {detail}", response=r)
f"{detail} (the pod answered {r.status_code})" if detail
else f"the pod answered {r.status_code} with no explanation",
response=r)
def _teach(self, base: str, bearer: str = "") -> bool:
"""Tell this pod about our model after it said it has never heard of it.
......
......@@ -75,13 +75,36 @@ class VllmBackend(ModelBackend):
_ctx = int(_ctx) if _ctx else 0
except (TypeError, ValueError):
_ctx = 0
if _ctx > 0:
self._ctx = _ctx
model_path = self._resolve_model_dir(model_name)
# This model's own `vllm` block over the global template: parallelism,
# the machines it spans, memory fraction, flags.
from codai.backends.overrides import VLLM_FIELDS, apply, model_block
self._cfg = apply(self._cfg, model_block(model_name, "vllm"), VLLM_FIELDS, "vllm")
_blk = model_block(model_name, "vllm") or {}
self._cfg = apply(self._cfg, _blk, VLLM_FIELDS, "vllm")
# The window this model is SERVED with, which is the only one worth
# reporting: the model's own `vllm.ctx` when it sets one, else a
# per-model `n_ctx`, else the global template. The generic kwarg must not
# win over an explicit block, because it can be a node-wide fallback
# nobody chose for this model -- `vulkan.n_ctx`, whose default is 2048 --
# and a 2048 reported against a 32768 server makes auto-compaction crush
# every conversation to a sixteenth of the window it has (measured on a
# production install 2026-10-22).
_blk_ctx = 0
try:
_blk_ctx = int((_blk or {}).get("ctx") or 0)
except (TypeError, ValueError):
_blk_ctx = 0
if not _blk_ctx and _ctx > 0:
# An explicit per-model n_ctx has to reach the LAUNCH too, or the
# reported window and the served one disagree by design.
try:
self._cfg = apply(self._cfg, {"ctx": _ctx}, VLLM_FIELDS, "vllm")
except Exception:
pass
try:
self._ctx = int(getattr(self._cfg, "ctx", 0) or 0) or self._ctx
except (TypeError, ValueError):
pass
# Per-model: serve each model-list entry under ITS OWN name (like nvidia/radeon),
# not a single engine-wide model_id. Fall back to the config model_id only for the
# single-model convenience (a model with no list entry).
......@@ -136,6 +159,16 @@ class VllmBackend(ModelBackend):
return self._model_id
def get_context_size(self) -> int:
"""The window this model is served with.
The SERVED number when the server has already been asked for it (see
context_length), because that is the only one that can refuse a request;
the configured one otherwise. They disagree in practice, and a caller
sizing a conversation against the larger of the two gets a 400.
"""
served = getattr(self, "_ctx_len", None)
if isinstance(served, int) and served > 0:
return served
return self._ctx
def get_last_usage(self) -> dict:
......@@ -284,11 +317,18 @@ class VllmBackend(ModelBackend):
if len(detail) > 2000:
detail = detail[:2000] + " ...[truncated]"
import requests as _rq
# What the server SAID comes first, the status line and URL after it.
# This error is wrapped again by every layer above -- a pod's coderai,
# then the orchestrator -- and each prepends its own prefix, so putting
# a 120-character proxy URL in front of the explanation pushes it past
# wherever the client truncates. The numbers in "maximum context length
# is 32768 ... you requested 35000" are the whole message, and a caller
# that reads only a prefix was getting none of them.
where = (f"{code} {getattr(r, 'reason', '')} from "
f"{getattr(r, 'url', '?')} on {what}")
raise _rq.HTTPError(
f"{code} {getattr(r, 'reason', '')} from "
f"{getattr(r, 'url', '?')} on {what}"
+ (f": {detail}" if detail else " (the server sent no explanation)"),
response=r)
f"{detail} ({where})" if detail
else f"{where} (the server sent no explanation)", response=r)
#: Never overridable by a caller: these are the request's structure, not its
#: sampling. Letting `passthrough` rewrite them would let a client retarget
......
......@@ -421,8 +421,19 @@ class CompactionConfig:
Per-model settings in a models.json entry (``auto_compact``,
``auto_compact_pct``, ``auto_compact_strategy``, ``auto_compact_model``,
``auto_compact_tolerance_pct``) OVERRIDE the values here; when a model leaves
one unset, the global default below applies. ``model`` selects which model
``auto_compact_tolerance_pct``, ``auto_compact_head_messages``,
``auto_compact_head_tokens``, ``auto_compact_tail_tokens``,
``auto_compact_target_pct``, ``auto_compact_retry_on_overflow``) OVERRIDE the
values here; when a model leaves one unset, the global default below applies.
The strategies are ``drop_oldest`` (keep the system messages and the recent
tail), ``keep_head_tail`` (also keep the opening turns, drop the centre) and
``summarize`` (keep both ends and replace the centre with an LLM summary of
exactly the messages being dropped -- "keep the start and the end, compact
the middle"; ``summarize_middle`` is accepted as a name for it). An unknown
name is read as ``keep_head_tail`` rather than silently dropping the head.
``model`` selects which model
performs the summarization for the ``summarize`` strategy — empty means use
the same model that serves the request. Pointing it at a smaller/faster model
lets that model summarize the old turns while the big model answers; the
......@@ -442,6 +453,22 @@ class CompactionConfig:
tolerance_pct: int = 20 # accept prompt+max_tokens up to this % over n_ctx (trim instead of compact)
min_output: int = 512 # always try to leave at least this many tokens for the reply
estimate_safety: float = 1.15 # inflate the chars/4 prompt estimate by this factor for fit/trim (it undercounts code/JSON)
# What the kept ends are, for keep_head_tail and summarize. The head is the
# opening instructions a long conversation keeps drifting away from, so it is
# sized in MESSAGES (one turn is the anchor; a handful is a briefing), while
# the tail is sized in TOKENS because what matters there is how much recent
# conversation survives verbatim, not how many turns it took. 0 means "as
# much as the target allows": the whole remaining budget for the tail, half
# of it for the head.
head_messages: int = 1 # leading turns kept verbatim (from the first user turn)
head_tokens: int = 0 # cap on the head, in tokens (0 = half the target)
tail_tokens: int = 0 # recent turns kept verbatim, in tokens (0 = the rest)
target_pct: int = 65 # compact DOWN to this % of n_ctx (leaves room to grow)
# Retry a request an engine refused for length, compacting against the window
# and prompt size the refusal reports. On by default: without it a caller is
# told to shorten a conversation coderai was configured to shorten for them,
# and the correction is learned too late to help that request.
retry_on_overflow: bool = True
@dataclass
......@@ -2355,6 +2382,11 @@ class ConfigManager:
"tolerance_pct": self.config.compaction.tolerance_pct,
"min_output": self.config.compaction.min_output,
"estimate_safety": self.config.compaction.estimate_safety,
"head_messages": self.config.compaction.head_messages,
"head_tokens": self.config.compaction.head_tokens,
"tail_tokens": self.config.compaction.tail_tokens,
"target_pct": self.config.compaction.target_pct,
"retry_on_overflow": self.config.compaction.retry_on_overflow,
},
"broker": {
"enabled": self.config.broker.enabled,
......
"""Keeping an over-long conversation inside the window, and saying so when it won't.
Measured on the Aruba production install 2026-10-22, which is why this exists:
`qwen38` is served from a rented A40 pod with `--max-model-len 32768`, clients
sent conversations longer than that, and the requests were simply refused --
auto-compaction has shipped for a while but defaults to OFF, so nothing shrank
them.
Three things are tested here, because all three were wrong in a way that only
showed up end to end:
* the SPLIT -- what survives. The head was reserved AFTER the tail was filled,
so head+tail could overshoot the target by the whole head.
* the SUMMARY -- what gets summarized. It was built from "everything but the
last two turns", which includes the messages kept verbatim, so the kept ends
appeared twice in a window that was already too small.
* the REFUSAL -- what the client is told. The numbers were buried behind an
apology, inside a pod's wrapper, inside the orchestrator's: a client reading
a truncated prefix got three layers of prose and neither token count.
"""
import asyncio
import pytest
from codai.api import context_overflow as co
from codai.api.text import (_compact_after_refusal, _compact_messages,
_compact_strategy, _estimate_tokens,
_split_for_compaction)
def setup_function():
co.forget()
# --------------------------------------------------------- reading a refusal #
#: Exactly as vLLM 0.27.1 refuses one, which is what a pod answers with.
VLLM = ("This model's maximum context length is 32768 tokens. However, you "
"requested 35000 tokens (34000 in the messages, 1000 in the "
"completion). Please reduce the length of the messages or completion.")
def test_the_vllm_refusal_gives_up_all_four_numbers():
got = co.parse(VLLM)
assert got == {"limit": 32768, "requested": 35000,
"prompt": 34000, "completion": 1000}
def test_the_v1_engine_phrasing_parses_too():
"""Same condition, different words, and no total -- so the total is derived."""
got = co.parse("The decoder prompt (length 41000) is longer than the "
"maximum model length of 32768.")
assert got["limit"] == 32768
assert got["prompt"] == 41000
assert got["requested"] == 41000
def test_llama_cpp_phrasing_parses():
got = co.parse("Requested tokens (5000) exceed context window of 4096")
assert got["limit"] == 4096 and got["requested"] == 5000
def test_our_own_message_parses_back():
"""A downstream coderai's refusal reaches us as text; it must read as well as
an engine's, or a pod in front of a pod loses the numbers."""
first = co.message({"limit": 32768, "requested": 35000, "prompt": 34000},
model="qwen38")
got = co.parse(first)
assert got["limit"] == 32768 and got["requested"] == 35000
def test_an_unrelated_error_is_not_an_overflow():
assert co.parse("401 Unauthorized") is None
assert not co.is_overflow("the pod answered 502: bad gateway")
assert not co.is_overflow("")
def test_an_overflow_without_numbers_is_still_recognised():
"""A retry needs numbers, but the CLASSIFICATION must not: calling this a
plain 500 loses the one error a client can act on."""
assert co.is_overflow("the request exceeds the available context size")
assert co.is_overflow("KV cache is full and context shift is disabled")
# ------------------------------------------------------- answering with one #
def test_the_numbers_come_before_the_apology():
"""THE TRAP: this message is wrapped by the pod's coderai and then by the
orchestrator, each adding a prefix and a 120-character proxy URL. A client
truncating at 300 characters got the apologies and no figures, so it could
not tell this from any other failure -- measured in production."""
msg = co.message(co.parse(VLLM), model="qwen38")
assert msg.startswith("context_length_exceeded:")
head = msg[:90]
assert "35000" in head and "32768" in head
assert "qwen38" in msg
def test_the_engine_s_own_words_are_kept_after_the_numbers():
msg = co.message(co.parse(VLLM), model="m", raw=VLLM)
assert msg.index("32768") < msg.index("engine said")
def test_a_message_without_numbers_still_names_the_condition():
msg = co.message(None, model="m")
assert msg.startswith("context_length_exceeded:")
def test_the_window_an_engine_reports_is_remembered():
"""The configured ctx is what coderai ASKS for; the served window is what can
refuse a request, and only a refusal reveals the two disagreeing."""
assert co.learned_limit("qwen38") is None
co.learn_from("qwen38", VLLM)
assert co.learned_limit("qwen38") == 32768
co.forget("qwen38")
assert co.learned_limit("qwen38") is None
def test_a_refusal_with_no_limit_teaches_nothing():
co.learn_from("m", "the request exceeds the available context size")
assert co.learned_limit("m") is None
# ---------------------------------------------------------------- the split #
def _convo(n, chars=400):
"""n alternating turns, each ~chars/4 tokens, numbered so they can be told
apart after compaction."""
out = [{"role": "system", "content": "SYSTEM RULES"}]
for i in range(n):
out.append({"role": "user" if i % 2 == 0 else "assistant",
"content": f"msg{i} " + ("x" * chars)})
return out
def test_the_middle_is_what_gets_dropped():
msgs = _convo(20)
sys_m, head, middle, tail = _split_for_compaction(
msgs, target=600, strategy="summarize", head_messages=2)
assert [m["content"].split()[0] for m in head] == ["msg0", "msg1"]
assert tail and tail[-1] is msgs[-1] # the live turn survives
assert middle # something was actually lost
# Every message is accounted for exactly once.
assert len(sys_m) + len(head) + len(middle) + len(tail) == len(msgs)
def test_the_head_is_reserved_before_the_tail_is_filled():
"""THE BUG: the tail used to be filled to the whole target and the head added
afterwards, so the result overshot the target by the size of the head -- and
was refused for the very length compaction had just been run to avoid."""
msgs = _convo(30)
target = 900
sys_m, head, _, tail = _split_for_compaction(
msgs, target=target, strategy="keep_head_tail", head_messages=3)
assert head, "the head is the point of this strategy"
kept = _estimate_tokens(sys_m + head + tail)
assert kept <= target
def test_a_big_head_cannot_starve_the_recent_end():
msgs = _convo(40)
_, head, _, tail = _split_for_compaction(
msgs, target=1000, strategy="keep_head_tail", head_messages=99)
assert tail, "asking for a huge head must still leave recent turns"
assert _estimate_tokens(head) <= _estimate_tokens(tail) + 200
def test_the_head_can_be_capped_in_tokens():
msgs = _convo(40)
_, head, _, _ = _split_for_compaction(
msgs, target=4000, strategy="keep_head_tail",
head_messages=10, head_tokens=250)
assert head and _estimate_tokens(head) <= 250 + _estimate_tokens([msgs[1]])
def test_the_tail_can_be_capped_in_tokens():
msgs = _convo(40)
_, _, middle, tail = _split_for_compaction(
msgs, target=8000, strategy="keep_head_tail", tail_tokens=300)
assert _estimate_tokens(tail) <= 300 + _estimate_tokens([msgs[-1]])
assert middle, "a capped tail must leave a middle to compact"
def test_drop_oldest_keeps_no_head():
_, head, middle, tail = _split_for_compaction(
_convo(20), target=600, strategy="drop_oldest", head_messages=5)
assert head == []
assert middle and tail
def test_the_kept_tail_never_starts_on_an_orphan_tool_result():
"""A tool result whose assistant tool_calls turn was dropped makes a chat
template raise -- the request fails for the shape of the history, not its
length."""
msgs = [{"role": "system", "content": "s"}]
for i in range(8):
msgs.append({"role": "user", "content": f"q{i} " + "x" * 400})
msgs.append({"role": "assistant", "content": "",
"tool_calls": [{"id": f"c{i}", "type": "function",
"function": {"name": "f", "arguments": "{}"}}]})
msgs.append({"role": "tool", "tool_call_id": f"c{i}",
"content": "result " + "y" * 400})
msgs.append({"role": "user", "content": "now answer"})
_, _, _, tail = _split_for_compaction(msgs, target=500, strategy="drop_oldest")
assert tail[0]["role"] != "tool"
def test_the_head_never_ends_on_an_unanswered_tool_call():
"""The mirror of the rule above: an assistant tool_calls turn kept in the head
whose results are in the dropped middle breaks the same templates."""
msgs = [{"role": "system", "content": "s"},
{"role": "user", "content": "q " + "x" * 400},
{"role": "assistant", "content": "",
"tool_calls": [{"id": "c1", "type": "function",
"function": {"name": "f", "arguments": "{}"}}]},
{"role": "tool", "tool_call_id": "c1", "content": "r " + "y" * 2000}]
msgs += [{"role": "user", "content": f"m{i} " + "x" * 400} for i in range(10)]
_, head, _, _ = _split_for_compaction(
msgs, target=700, strategy="keep_head_tail", head_messages=2)
assert not any(m.get("tool_calls") for m in head)
# ------------------------------------------------------------ the assembling #
def test_compaction_keeps_the_system_prompt_and_the_live_turn():
msgs = _convo(30)
out, info = _compact_messages(msgs, n_ctx=2000, pct=85, strategy="keep_head_tail")
assert info and info["dropped"]
assert out[0]["content"] == "SYSTEM RULES"
assert out[-1] is msgs[-1]
assert info["after_tokens"] < info["before_tokens"]
def test_a_conversation_that_fits_is_left_alone():
msgs = _convo(2)
out, info = _compact_messages(msgs, n_ctx=32768, pct=85, strategy="summarize")
assert info is None and out is msgs
def test_the_summary_is_inserted_as_a_system_note():
msgs = _convo(30)
out, info = _compact_messages(msgs, n_ctx=2000, pct=85, strategy="summarize",
summary_text="THE GIST")
assert any("THE GIST" in str(m.get("content")) for m in out)
assert info["strategy"] == "summarize"
def test_without_a_summary_the_loss_is_still_declared():
"""Silently shorter history makes a model look like it forgot; the note is
what tells the model (and a reader of the log) that it was cut."""
msgs = _convo(30)
out, _ = _compact_messages(msgs, n_ctx=2000, pct=85, strategy="keep_head_tail")
assert any("omitted to fit the context window" in str(m.get("content"))
for m in out)
def test_the_target_percentage_is_honoured():
msgs = _convo(60)
tight, _ = _compact_messages(msgs, n_ctx=4000, pct=80,
strategy="keep_head_tail", target_pct=25)
loose, _ = _compact_messages(msgs, n_ctx=4000, pct=80,
strategy="keep_head_tail", target_pct=80)
assert _estimate_tokens(tight) < _estimate_tokens(loose)
def test_an_unknown_strategy_keeps_the_ends_rather_than_dropping_the_head():
"""A typo in a config must not quietly throw away the opening instructions:
that looks like a model ignoring its brief, with nothing to point at."""
assert _compact_strategy("summarize_middle") == "summarize"
assert _compact_strategy("Keep-Head-Tail") == "keep_head_tail"
assert _compact_strategy("summarise") == "summarize"
assert _compact_strategy("nonsense") == "keep_head_tail"
assert _compact_strategy("") == "drop_oldest"
assert _compact_strategy(None) == "drop_oldest"
# ------------------------------------------------------- retry on a refusal #
class _FakeManager:
"""Enough of a manager for the compaction plan: a window and a summarizer."""
def __init__(self, n_ctx=32768):
self._n = n_ctx
self.model_name = "fake"
self.summarized = []
def get_context_size(self):
return self._n
def generate_chat(self, messages=None, **kw):
self.summarized.append(messages)
return "SUMMARY OF THE MIDDLE"
def _plan(messages, **over):
plan = {"pct": 85.0, "strategy": "summarize", "n_ctx": 32768,
"compact_model": "", "current_manager": _FakeManager(),
"model": "qwen38", "max_tokens": 1000, "tolerance": 1.2,
"min_output": 512, "safety": 1.15, "head_messages": 1,
"head_tokens": 0, "tail_tokens": 0, "target_pct": 65.0,
"retry_on_overflow": True}
plan.update(over)
return plan
def test_a_refusal_plans_the_retry_from_its_own_numbers():
"""The estimate let this prompt through, so repeating it with the same
estimate would repeat the refusal. The engine counted the prompt for real;
that count is what the retry is planned against."""
msgs = _convo(40, chars=800)
plan = _plan(msgs)
got = asyncio.run(_compact_after_refusal(plan, msgs, "qwen38", VLLM))
assert got is not None
out, eff, info = got
assert info and info["dropped"]
assert _estimate_tokens(out) < _estimate_tokens(msgs)
assert co.learned_limit("qwen38") == 32768 # and remembered for next time
def test_the_retry_is_abandoned_when_the_refusal_taught_nothing():
"""No numbers means no better plan, and re-sending an identical request is
worse than reporting the error."""
msgs = _convo(40)
got = asyncio.run(_compact_after_refusal(
_plan(msgs), msgs, "m", "the request exceeds the available context size"))
assert got is None
def test_the_retry_respects_a_window_smaller_than_the_configured_one():
"""A proxied model is launched elsewhere: `ctx: 32768` in models.json is a
request, not a fact, and a pod started with half of it refuses accordingly."""
msgs = _convo(60, chars=600)
plan = _plan(msgs, n_ctx=131072)
got = asyncio.run(_compact_after_refusal(
plan, msgs, "qwen38",
"This model's maximum context length is 8192 tokens. However, you "
"requested 40000 tokens (39000 in the messages, 1000 in the completion)."))
assert got is not None
out, _eff, _info = got
assert _estimate_tokens(out) < 8192
assert co.learned_limit("qwen38") == 8192
def test_the_summarizer_is_given_only_the_messages_being_dropped():
"""THE BUG: the summary was built from everything but the last two turns, so
the head and most of the kept tail were summarized AND kept verbatim --
paying twice for the same words in a window that was already short, and
presenting a paraphrase of the live conversation as earlier history."""
msgs = _convo(40, chars=800)
mgr = _FakeManager()
plan = _plan(msgs, current_manager=mgr, strategy="summarize")
out, _eff, _info = asyncio.run(
_compact_after_refusal(plan, msgs, "qwen38", VLLM))
assert mgr.summarized, "the summarize strategy must have summarized something"
handed = "\n".join(str(m[-1].get("content")) for m in mgr.summarized)
kept = [str(m.get("content")) for m in out if "msg" in str(m.get("content"))]
first_kept = next((c.split()[0] for c in kept), None)
last_kept = kept[-1].split()[0] if kept else None
assert first_kept and last_kept
# The messages that SURVIVED verbatim are not in the summarizer's input.
assert f"{first_kept} " not in handed
assert f"{last_kept} " not in handed
# ...and something from the middle is.
assert "msg20" in handed or "msg15" in handed
"""The window a proxied model reports must be the one it is served with.
2026-10-22, production: `qwen38` is served from a rented pod launched with
`--max-model-len 32768`, `/v1/models` published 32768 -- and every answer's
`usage.context_size` said **2048**. Two numbers for one model, from one process.
The cause is a precedence, not a typo. A pod-served model's window lives in its
`runpod` block (`ctx`, which becomes the pod's `--max-model-len`), while the
backend also accepts a generic `n_ctx` kwarg -- and that kwarg carries the
NODE-WIDE fallback `vulkan.n_ctx` when the model's entry sets no `n_ctx` of its
own. Its default is 2048. The kwarg was applied last, so a global default for
local llama.cpp models silently became the published window of every pod-served
model on the install.
Reporting 2048 was the visible half. The dangerous half is auto-compaction,
which compacts to a percentage of exactly this number: enabling it would have
crushed every conversation to ~1300 tokens to fit a window sixteen times
larger, and the summaries would have looked like a model that could not
remember anything.
"""
import types
import pytest
from codai.backends.runpod import RunpodBackend
from codai.backends.vllm import VllmBackend
# --------------------------------------------------------------- pods (proxy) #
def _runpod(monkeypatch, block_ctx, **load_kwargs):
"""A RunpodBackend loaded with a runpod block of ``block_ctx`` tokens."""
b = RunpodBackend.__new__(RunpodBackend)
b._ctx = 32768
b._model_id = b._served_name = "qwen38"
b._acct = None
b._headers = {}
b._pool = None
mcfg = types.SimpleNamespace(
ctx=block_ctx, served_model="", mode="serverless", endpoint_id="e1",
min_pods=0, max_pods=1)
monkeypatch.setattr("codai.api.runpod_worker.model_runpod_block",
lambda name: {}, raising=False)
monkeypatch.setattr("codai.api.runpod_worker.parse_model_runpod",
lambda blk: mcfg, raising=False)
monkeypatch.setattr("codai.api.runpod_worker.serverless_base_url",
lambda acct, cfg: "https://pod:1/v1", raising=False)
monkeypatch.setattr("codai.api.runpod_worker.auth_headers",
lambda acct: {}, raising=False)
b.load_model("qwen38", **load_kwargs)
return b
def test_the_pods_own_ctx_beats_the_node_wide_default(monkeypatch):
"""THE BUG: `vulkan.n_ctx` (default 2048) arrived as n_ctx and overwrote the
32768 the pod is actually launched with."""
b = _runpod(monkeypatch, 32768, n_ctx=2048)
assert b.get_context_size() == 32768
def test_a_model_with_no_pod_ctx_still_takes_the_kwarg(monkeypatch):
"""The fallback is a fallback, not dead code: with nothing configured for the
pod, the only number available is the one passed in."""
b = _runpod(monkeypatch, 0, n_ctx=8192)
assert b.get_context_size() == 8192
def test_with_neither_the_backend_keeps_its_own_default(monkeypatch):
b = _runpod(monkeypatch, 0)
assert b.get_context_size() == 32768
def test_a_junk_kwarg_cannot_shrink_the_window(monkeypatch):
"""A list or a string reaching n_ctx must not silently become the window --
and must not raise inside the request path either."""
for junk in ([], "", "nonsense", None, [4096]):
b = _runpod(monkeypatch, 65536, n_ctx=junk)
assert b.get_context_size() == 65536
# ------------------------------------------------------------- vLLM (in-process)
def _vllm(monkeypatch, block, **load_kwargs):
from codai.config import VllmConfig
b = VllmBackend.__new__(VllmBackend)
b._cfg = VllmConfig()
b._model_id = "m"
b._svc_key = None
b._url = None
b._ctx = int(b._cfg.ctx)
b._served_name = "m"
b._last_usage = {}
b._last_logprobs = None
b._last_tool_calls = None
b._last_reasoning = None
monkeypatch.setattr(b, "_resolve_model_dir", lambda name: "/models/m")
monkeypatch.setattr("codai.backends.overrides.model_block",
lambda name, key: dict(block))
monkeypatch.setattr("codai.api.vllm_worker.resolve_service_key",
lambda cfg, path: (path, "svc"), raising=False)
monkeypatch.setattr("codai.api.vllm_worker.ensure_service",
lambda cfg, **kw: "http://127.0.0.1:41721", raising=False)
b.load_model("m", **load_kwargs)
return b
def test_the_models_vllm_block_beats_the_node_wide_default(monkeypatch):
b = _vllm(monkeypatch, {"ctx": 131072}, n_ctx=2048)
assert b.get_context_size() == 131072
def test_a_per_model_n_ctx_reaches_the_launch_as_well(monkeypatch):
"""Reporting a window the server was not started with is the same bug in
reverse, so an explicit n_ctx has to change what is launched too."""
b = _vllm(monkeypatch, {}, n_ctx=65536)
assert b.get_context_size() == 65536
assert int(b._cfg.ctx) == 65536 # ...and that is what vLLM is given
def test_the_served_window_wins_once_the_server_has_been_asked(monkeypatch):
"""The configured number is a request; the served one can refuse a request.
vLLM reports its own on /v1/models, and once that answer is in hand it is
the truth -- measured on an install where `ctx: 18432` was configured and
`--max-model-len 32768` was served."""
b = _vllm(monkeypatch, {"ctx": 18432})
assert b.get_context_size() == 18432
b._ctx_len = 32768 # as context_length() caches it
assert b.get_context_size() == 32768
def test_an_unknown_served_window_is_not_mistaken_for_zero(monkeypatch):
b = _vllm(monkeypatch, {"ctx": 32768})
b._ctx_len = None # context_length() tried and failed
assert b.get_context_size() == 32768
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