project-nomad/admin/app/utils/context_window.ts
Jake Turner 9a95f7b2b8 feat(AI): budget the prompt and actually set the context window (#1253)
Chat was run through Ollama's OpenAI-compatible /v1 endpoint, which has no
context-size field, so the num_ctx we sent was silently discarded and the
entire NUM_CTX_LADDER was dead code. Every conversation ran at the backend
default and was truncated from the middle (where history and
retrieved context actually sit). Measured with identical ~7k-token prompts:
2,060 tokens processed against 7,049 on the native endpoint, and with the
needed fact in the discarded middle the old path hallucinated a plausible
wrong number.

Transport: add a native /api/chat path using the already-installed ollama
package, sending a real options object (num_ctx, num_predict, temperature,
seed) plus keep_alive. The OpenAI path is still used for LM Studio and llama.cpp,
minus the ineffective num_ctx and with include_usage so both paths return
prompt token counts. /api/show is now read for trained context, parameter
size, quantization and any modelfile num_ctx. The <think> stream normalizer
moves to app/utils/think_stream.ts so both transports share it; it gains a
flush() that fixes a dangling partial tag being dropped at end of stream.

Token accounting: replace chars/N with a structural segmenter scaled by a
per-model factor learned by EWMA from the prompt_eval_count every response
already returned and threw away. End-to-end estimator error is 4.6% against
18-20% for a fixed divisor — qwen2.5 converges to k=1.26 where llama3 sits
at 1.00, so no single global constant serves both.

Context window: resolved once per model from trained context, the exact KV
cost derived from GGUF metadata (head_count_kv, not head_count — GQA makes
them differ 4x), and available VRAM, then memoized. Memoization is
load-bearing: asking for a different num_ctx makes Ollama unload and reload
the model, stalling the turn and discarding the KV cache. The floor never
overrides the model's trained ceiling — tinyllama trains to 2048, and a
blind 4096 floor pushes RoPE past anything the weights saw.

Prompt budget: planPrompt allocates explicitly instead of delegating
overflow to silent backend truncation — response reserve, then fixed blocks
(system and current query, never dropped), then RAG chunks whole and
best-first, then history in whole user+assistant turns, evicted newest-first
in blocks with an elision marker. Block eviction keeps the prefix stable for
several turns rather than shifting on every one.

KV-cache ordering: the per-turn retrieved block moves behind history to the
tail, so the stable prefix stays byte-identical turn to turn. At turn 14 of
a long conversation, prefill is 231ms at the tail against 539ms at the front
(~1ms/turn growth against ~25ms). The win is conditional — with a large
retrieved block and short history the two measure the same — so both
orderings ship behind RAG_PLACEMENT. The per-turn query rewrite also moves
to the tasks model; on a single-slot Ollama it was evicting the chat model's
cached prefix on every single turn.

Also: OLLAMA_CONTEXT_LENGTH and OLLAMA_KV_CACHE_TYPE=q8_0 on the container
(q8_0 is treated as headroom, never as a budgeting assumption, since
unsupported architectures fall back to f16 silently), a Context Window
setting in Settings > Models, and budget fields on PipelineTrace.

Eval: golden suites (--suite) so the new long_context goldens cannot move
the aggregates the committed baselines were recorded against, plus budget
and estimator-error metrics through the report path. Retrieval is unchanged
and eval:compare reports no metric outside the tolerance band; the
long_context suite scores correctness 1.000 with historyElided 0.500 (the
two eviction cases, by design). Unit tests 337 -> 386 passing, same 7
pre-existing failures.

Ingest-side CHAR_TO_TOKEN_RATIO is deliberately untouched: changing it
re-chunks the corpus and invalidates retrieval baselines, which belongs in
its own measured change.
2026-09-29 15:54:41 -07:00

283 lines
11 KiB
TypeScript

/**
* Pure helpers for deciding how large a model's context window should be.
*
* Kept out of OllamaService so they can run under bare `node --test` with no
* Ollama, Docker, or MySQL — the same convention as `rag_prompt.ts`. The service
* supplies the facts (what /api/show said, how much memory is free); everything
* here is a function of its arguments.
*/
/**
* Ladder of context sizes we're willing to request.
*
* Snapping to a ladder is not cosmetic. Ollama unloads and reloads a model
* whenever a request asks for a different `num_ctx` than the loaded instance, so
* a continuously-varying window would stall a turn and throw away the KV cache
* every time the conversation grew. Snapping (plus per-model memoization in the
* resolver) keeps the value constant for a session.
*/
export const CONTEXT_LADDER = [4096, 8192, 16384, 32768, 65536, 131072] as const
/** Never go below this: it is roughly a system prompt plus a few real turns. */
export const MIN_CONTEXT = 4096
/**
* Fallback window when the backend can't tell us anything — a non-Ollama
* OpenAI-compatible server, or /api/show unavailable. Deliberately modest:
* budgeting too small wastes capacity, budgeting too large silently truncates.
*/
export const UNKNOWN_BACKEND_CONTEXT = 8192
/**
* Read the trained context length out of an /api/show `model_info` blob.
*
* Keys are architecture-prefixed (`llama.context_length`, `qwen3.context_length`,
* `gemma3.context_length`, ...), so prefer the prefix named by
* `general.architecture` and fall back to any `*.context_length` key. Ollama's
* client types this as a Map but the wire format is a plain object; handle both.
*/
export function readContextLength(modelInfo: unknown): number | undefined {
if (!modelInfo) return undefined
const entries: [string, any][] =
modelInfo instanceof Map
? [...modelInfo.entries()]
: typeof modelInfo === 'object'
? Object.entries(modelInfo as Record<string, any>)
: []
if (entries.length === 0) return undefined
const lookup = new Map(entries)
const arch = lookup.get('general.architecture')
if (typeof arch === 'string') {
const exact = lookup.get(`${arch}.context_length`)
if (typeof exact === 'number' && exact > 0) return exact
}
for (const [key, value] of entries) {
if (key.endsWith('.context_length') && typeof value === 'number' && value > 0) {
return value
}
}
return undefined
}
/**
* Pull `num_ctx` out of the `parameters` string /api/show returns (the modelfile
* PARAMETER lines, e.g. `num_ctx 8192`). When a model
* author baked in a window, that is a deliberate signal worth respecting as a
* ceiling hint.
*/
export function readModelfileNumCtx(parameters: unknown): number | undefined {
if (typeof parameters !== 'string') return undefined
const match = parameters.match(/^\s*num_ctx\s+(\d+)\s*$/m)
if (!match) return undefined
const value = Number.parseInt(match[1], 10)
return Number.isFinite(value) && value > 0 ? value : undefined
}
/**
* Parse Ollama's `details.parameter_size` ("8.0B", "1.5B", "70B") into billions.
* Falls back to the tag-name regex — which is what the codebase used to rely on
* exclusively, and which guesses wrong on names like "phi3" that carry no size.
*/
export function parseParameterBillions(
parameterSize: string | undefined,
modelName?: string
): number | undefined {
const fromDetails = parameterSize?.match(/([\d.]+)\s*B/i)
if (fromDetails) {
const value = Number.parseFloat(fromDetails[1])
if (Number.isFinite(value)) return value
}
const fromName = modelName?.match(/(\d+\.?\d*)\s*[bB](?![a-zA-Z])/)
if (fromName) {
const value = Number.parseFloat(fromName[1])
if (Number.isFinite(value)) return value
}
return undefined
}
/** Round a token count down to the largest ladder rung that fits. */
export function snapToLadder(tokens: number): number {
let chosen = MIN_CONTEXT
for (const rung of CONTEXT_LADDER) {
if (rung <= tokens) chosen = rung
}
return chosen
}
// ---------------------------------------------------------------------------
// How much memory a context window actually costs
// ---------------------------------------------------------------------------
/**
* Bytes per element in the KV cache.
*
* Budgeted at f16 (2 bytes) even though the Ollama container is started with
* `OLLAMA_KV_CACHE_TYPE=q8_0`. Ollama silently falls back to f16 on
* architectures that don't support cache quantization, so treating q8_0 as
* guaranteed would double the real cost of every window we hand out. Quantized
* cache is headroom, not a budgeting assumption.
*/
const KV_BYTES_PER_ELEMENT = 2
/**
* Exact KV-cache cost per token, from the model's own GGUF metadata.
*
* 2 (K and V) x layers x kv_heads x head_dim x bytes_per_element
*
* For llama3:8b that is 2 x 32 x 8 x 128 x 2 = 128 KiB per token, so an 8k
* window costs 1 GiB — which is why "just set num_ctx high" is not free advice
* on the hardware NOMAD targets.
*
* Returns undefined when the metadata is missing (non-Ollama backend, or an
* architecture that names its keys differently), leaving the caller to fall back
* to `estimateKvBytesPerToken`.
*/
export function computeKvBytesPerToken(modelInfo: unknown): number | undefined {
if (!modelInfo) return undefined
const entries: [string, any][] =
modelInfo instanceof Map
? [...modelInfo.entries()]
: typeof modelInfo === 'object'
? Object.entries(modelInfo as Record<string, any>)
: []
if (entries.length === 0) return undefined
const info = new Map(entries)
const arch = info.get('general.architecture')
if (typeof arch !== 'string') return undefined
const num = (suffix: string): number | undefined => {
const value = info.get(`${arch}.${suffix}`)
return typeof value === 'number' && value > 0 ? value : undefined
}
const layers = num('block_count')
// Grouped-query attention means KV heads are usually far fewer than attention
// heads; using head_count here would overestimate cost several-fold.
const kvHeads = num('attention.head_count_kv') ?? num('attention.head_count')
if (!layers || !kvHeads) return undefined
// Prefer explicit key/value lengths; otherwise derive head_dim from the
// embedding width and the attention head count.
let headDim = num('attention.key_length')
if (!headDim) {
const embedding = num('embedding_length')
const heads = num('attention.head_count')
if (embedding && heads) headDim = embedding / heads
}
if (!headDim) return undefined
return 2 * layers * kvHeads * headDim * KV_BYTES_PER_ELEMENT
}
/**
* Fallback KV cost per token when GGUF metadata is unavailable, interpolated
* from the common architectures at each size. Deliberately pessimistic — an
* overestimate costs a smaller window, an underestimate costs an OOM at load.
*/
export function estimateKvBytesPerToken(paramBillions: number | undefined): number {
const b = paramBillions ?? 8
if (b <= 1.5) return 32 * 1024
if (b <= 4) return 64 * 1024
if (b <= 9) return 128 * 1024
if (b <= 15) return 192 * 1024
if (b <= 35) return 256 * 1024
return 512 * 1024
}
/**
* Fraction of memory left free for weights-adjacent overhead: compute buffers,
* activations, the graph, and whatever else shares the device. Without this the
* arithmetic says a window fits right up to the byte and the model fails to load.
*/
const MEMORY_SAFETY_FRACTION = 0.8
export type ContextWindowInputs = {
/** Trained context length from /api/show. The hard ceiling. */
modelMaxCtx?: number
/** num_ctx baked into the modelfile, treated as an author's ceiling hint. */
modelfileNumCtx?: number
/** Cost of one token of KV cache, exact or estimated. */
kvBytesPerToken: number
/** Memory available for the model, in bytes (VRAM, or a share of system RAM). */
availableBytes?: number
/** On-disk size of the weights, in bytes — they occupy the same pool. */
modelBytes?: number
/** User cap from `ai.contextWindow`. Only ever lowers the result. */
userCap?: number
}
export type ContextWindowDecision = {
contextWindow: number
/** Which constraint actually bound the result — surfaced in logs and the UI. */
limitedBy: 'user' | 'model' | 'memory' | 'floor' | 'default'
affordableTokens?: number
}
/**
* Decide the context window for a model.
*
* The result must be *stable for the life of the process*: Ollama unloads and
* reloads a model whenever a request asks for a different num_ctx than the
* loaded instance, which stalls the turn and discards the KV cache. Callers
* memoize per model; this function is pure so that memoization is safe.
*/
export function resolveContextWindow(inputs: ContextWindowInputs): ContextWindowDecision {
const ceilings: Array<{ value: number; source: ContextWindowDecision['limitedBy'] }> = []
if (inputs.modelMaxCtx && inputs.modelMaxCtx > 0) {
ceilings.push({ value: inputs.modelMaxCtx, source: 'model' })
}
if (inputs.modelfileNumCtx && inputs.modelfileNumCtx > 0) {
ceilings.push({ value: inputs.modelfileNumCtx, source: 'model' })
}
if (inputs.userCap && inputs.userCap > 0) {
ceilings.push({ value: inputs.userCap, source: 'user' })
}
let affordableTokens: number | undefined
if (inputs.availableBytes && inputs.availableBytes > 0 && inputs.kvBytesPerToken > 0) {
const forKv = inputs.availableBytes * MEMORY_SAFETY_FRACTION - (inputs.modelBytes ?? 0)
affordableTokens = Math.max(0, Math.floor(forKv / inputs.kvBytesPerToken))
ceilings.push({ value: affordableTokens, source: 'memory' })
}
if (ceilings.length === 0) {
return { contextWindow: UNKNOWN_BACKEND_CONTEXT, limitedBy: 'default' }
}
const binding = ceilings.reduce((lowest, c) => (c.value < lowest.value ? c : lowest))
const snapped = snapToLadder(binding.value)
// The floor wins over a *memory* shortfall: a window under 4k is not worth
// having, and Ollama will offload to host RAM rather than refuse. It must
// never win over the model itself, though — asking for more than the trained
// context pushes RoPE past what the weights ever saw and degrades output.
// tinyllama (trained 2048) is the case that catches this.
const hardCeiling = Math.min(
...[inputs.modelMaxCtx, inputs.modelfileNumCtx, inputs.userCap].filter(
(v): v is number => typeof v === 'number' && v > 0
),
Number.POSITIVE_INFINITY
)
if (snapped <= MIN_CONTEXT) {
const floored = Math.min(MIN_CONTEXT, hardCeiling)
return {
contextWindow: floored,
limitedBy: floored < MIN_CONTEXT ? 'model' : 'floor',
affordableTokens,
}
}
return { contextWindow: snapped, limitedBy: binding.source, affordableTokens }
}
/** Parse the `ai.contextWindow` setting. "auto"/unset/garbage all mean "no cap". */
export function parseUserContextCap(raw: string | null | undefined): number | undefined {
if (!raw || raw === 'auto') return undefined
const value = Number.parseInt(raw, 10)
return Number.isFinite(value) && value >= MIN_CONTEXT ? value : undefined
}