|
| 1 | +# Context windows |
| 2 | + |
| 3 | +A model's context window on Logos is not a property of the model. It is a |
| 4 | +property of the lane serving it: the capacity planner sizes a lane's KV cache |
| 5 | +from the VRAM free on the node it lands on, and the window follows from that. |
| 6 | +The same model can therefore be served at 262,144 tokens on one worker and a |
| 7 | +fraction of that on another, and a re-calibration moves it again without the |
| 8 | +model changing. |
| 9 | + |
| 10 | +That has one consequence that shapes everything below: **a single number cannot |
| 11 | +be both safe and useful.** A request may be routed to any deployment serving the |
| 12 | +model, so the only window that always holds is the smallest one — and |
| 13 | +advertising that turns a 262k model into a 33k model for every client that sizes |
| 14 | +its conversation from what it is told. |
| 15 | + |
| 16 | +This document covers the four places that fact shows up. |
| 17 | + |
| 18 | +## 1. What the API reports |
| 19 | + |
| 20 | +`GET /v1/models` (and `/v1/models/{id}`) carry up to four fields per model. Each |
| 21 | +is omitted when unknown, so cloud models and never-calibrated models keep the |
| 22 | +object they had before any of this existed. |
| 23 | + |
| 24 | +| Field | Meaning | |
| 25 | +| --------------------------- | -------------------------------------------------------------------------- | |
| 26 | +| `max_model_len_current_min` | Smallest window being served right now. Holds whichever deployment answers, so a client that never wants a rejected request sizes itself from this. | |
| 27 | +| `max_model_len_current_max` | Largest window being served right now. Reachable because of the routing in §3. | |
| 28 | +| `max_model_len_overall` | The widest this model is ever served with — what a lane runs at once it gets all the KV cache it asks for. Independent of what is loaded at the moment, so it is known even for a model with no live lane, and it is the number to write into a config file that is only read at startup. | |
| 29 | +| `max_model_len` | Repeats `max_model_len_current_min` under the name vLLM itself uses, so an OpenAI-compatible client that already reads that field keeps working. | |
| 30 | + |
| 31 | +The same three are exposed to the Spring webservice on |
| 32 | +`GET /internal/model_context_windows` as `stats` (`current_min`, `current_max`, |
| 33 | +`overall`), alongside the original flat `windows` map (model → smallest window) |
| 34 | +that predates them. From there they reach the UI as |
| 35 | +`context_window_current_min`, `context_window_current_max` and |
| 36 | +`context_window_overall` on `GET /me/keys/{id}/models`. |
| 37 | + |
| 38 | +Source: `_served_context_window_stats()` in `logos/main.py`. |
| 39 | + |
| 40 | +## 2. Placement floor — don't create a narrow lane at all |
| 41 | + |
| 42 | +Since the narrowest lane defines what every client is told, a worker can refuse |
| 43 | +to host a model below a share of its own context length. The floor is set **per |
| 44 | +model in the worker's `config.yml`**, because the worker's hardware is what |
| 45 | +decides which windows are reachable there: |
| 46 | + |
| 47 | +```yaml |
| 48 | +logos: |
| 49 | + capabilities_models: |
| 50 | + # Only worth serving at its full context — place it here or not at all. |
| 51 | + - model: Qwen/Qwen3.8-27B |
| 52 | + min_context_fraction: 1.0 |
| 53 | + |
| 54 | + # Fine at anything from half its context up. |
| 55 | + - model: openai/gpt-oss-120b |
| 56 | + min_context_fraction: 0.5 |
| 57 | + |
| 58 | + # No entry (or 0) = place at any width, the behaviour from before this field. |
| 59 | + - some-org/small-chat-model |
| 60 | +``` |
| 61 | +
|
| 62 | +The value travels with the model profile in the worker's runtime snapshot, so |
| 63 | +the server picks it up without a restart of its own. It is enforced in two |
| 64 | +places: |
| 65 | +
|
| 66 | +- `_passes_minimum_load_feasibility` — the planner does not *propose* a load |
| 67 | + that cannot reach the floor. |
| 68 | +- `_select_kv_mb_max_model_len_pair` — the pair actually chosen at load time is |
| 69 | + constrained too, so a load path that bypasses the gate (contention, |
| 70 | + eviction-backed cold load, request-time cold load) cannot quietly place a |
| 71 | + below-floor lane either. When such a path has to place something anyway — a |
| 72 | + request is already waiting — it takes the **widest** fitting pair rather than |
| 73 | + the narrowest, and logs that it went below the floor. |
| 74 | + |
| 75 | +Two log lines to look for when a model is not being placed: |
| 76 | + |
| 77 | +```text |
| 78 | +Feasibility FAILED for <model>: smallest calibrated KV pair serving >=N context tokens needs … |
| 79 | +Feasibility FAILED for <model>: no calibrated KV point serves the required minimum of N context tokens (widest is M) |
| 80 | +``` |
| 81 | + |
| 82 | +The first is temporary — it clears when VRAM frees up. The second will not: no |
| 83 | +calibrated point on that node reaches the floor at any KV size, so either lower |
| 84 | +`min_context_fraction` for that model or re-calibrate it. |
| 85 | + |
| 86 | +A model whose context length is unknown is never blocked by the floor. It exists |
| 87 | +to stop the planner *choosing* a narrow window, not to keep uncalibrated models |
| 88 | +off the cluster. |
| 89 | + |
| 90 | +## 3. Context-aware routing — send long requests where they fit |
| 91 | + |
| 92 | +`_prefer_deployments_with_context_room` (`logos/main.py`) estimates what a |
| 93 | +request needs and drops the deployments that cannot serve it: |
| 94 | + |
| 95 | +```text |
| 96 | +needed = prompt tokens + the output the request reserved + 3000 tokens of margin |
| 97 | +``` |
| 98 | + |
| 99 | +**Where "the output the request reserved" comes from:** the request says so. |
| 100 | +`max_tokens` (Anthropic Messages, chat completions), `max_completion_tokens` or |
| 101 | +`max_output_tokens` (Responses API) — whichever is present. A request that names |
| 102 | +none is assumed to reserve 20,000, because an uncapped request can generate |
| 103 | +until it hits the window, and 20,000 is the largest default among the clients |
| 104 | +Logos serves. This matters because vLLM charges input and output against one |
| 105 | +budget: a prompt that fits on its own can still overflow once the reply it asked |
| 106 | +for is reserved. |
| 107 | + |
| 108 | +**Where the 3000 comes from:** it is the margin Claude Code keeps between its own |
| 109 | +hard stop and the limit it was told. Using the same number means a session that |
| 110 | +Claude Code considers safe is one this filter also considers safe. It absorbs the |
| 111 | +difference between the estimate and what the worker's tokenizer really counts — |
| 112 | +the estimate (`logos/context_budget.py`) counts characters and divides by 3, |
| 113 | +skipping base64 attachments, and rounds against itself at every step, because |
| 114 | +overestimating costs a roomier deployment while underestimating costs a 400. |
| 115 | + |
| 116 | +The margin is part of `needed`, not something the lane has to hold in addition: |
| 117 | +a lane serving 33,000 tokens is asked to fit `prompt + output + 3000 ≤ 33000`. |
| 118 | +So a lane never has to "first make room" for the margin — it is simply expected |
| 119 | +to have 3000 tokens more than the request strictly needs. |
| 120 | + |
| 121 | +Two deliberate escape hatches: |
| 122 | + |
| 123 | +- **A deployment whose window is unknown is always kept.** Cloud providers, and |
| 124 | + lanes that have not reported a window yet, have none; that is missing |
| 125 | + information, not evidence of a narrow window. It does mean |
| 126 | + `max_model_len_current_min` is only a promise across the deployments whose |
| 127 | + window is known — a request sized from it can still reach an unknown one. |
| 128 | +- **When nothing fits, the widest deployments are returned** rather than |
| 129 | + nothing. The request then fails upstream with the limit spelled out, exactly |
| 130 | + as before this filter existed, instead of turning into a 404 that names no |
| 131 | + model. |
| 132 | + |
| 133 | +Audio uploads are left alone: a transcription hint is a few words and says |
| 134 | +nothing about how much context the request needs. |
| 135 | + |
| 136 | +## 4. Clients |
| 137 | + |
| 138 | +### Claude Code — the `claude-logos` wrapper |
| 139 | + |
| 140 | +`logos-ui/public/claude-logos.sh` (and `.ps1` for Windows) is served at |
| 141 | +`<logos-url>/claude-logos.sh` and installed by the AI Tools page. At every start |
| 142 | +it asks `GET /v1/models`, prints the window it got, and exports the result into |
| 143 | +its own child process — nothing outside the wrapper is touched, so plain |
| 144 | +`claude` keeps using an Anthropic subscription unchanged. |
| 145 | + |
| 146 | +It also does two things with the listing it already has in hand: |
| 147 | + |
| 148 | +- **Warms the model up.** `POST /v1/models/{model}/warmup` tells the planner the |
| 149 | + model is about to be used and returns immediately. It records the same latent |
| 150 | + demand the scheduler records when classification prefers a model it did not |
| 151 | + get, and wakes the planner cycle early — so the cold load can overlap with the |
| 152 | + seconds a developer spends reading the startup line. It is a hint, not a |
| 153 | + reservation: the planner still decides using its own fairness rules, a warmup |
| 154 | + can never evict a lane real traffic is using, and no inference request is ever |
| 155 | + sent on the caller's behalf. Warming a model the key has no access to is a 404. |
| 156 | +- **Names models that are new to you.** The id list is compared against the one |
| 157 | + from the last run (`~/.config/claude-logos/known-models`); additions are |
| 158 | + printed. The first run records the baseline silently rather than announcing |
| 159 | + everything as new. |
| 160 | + |
| 161 | +`LOGOS_CONTEXT_SOURCE` picks which figure to size the session from: `available` |
| 162 | +(default, `max_model_len_current_max`), `guaranteed` |
| 163 | +(`max_model_len_current_min`) or `max` (`max_model_len_overall`). |
| 164 | + |
| 165 | +**The arithmetic matters, and it is not obvious.** Claude Code takes |
| 166 | +`CLAUDE_CODE_MAX_CONTEXT_TOKENS`, subtracts `min(CLAUDE_CODE_MAX_OUTPUT_TOKENS, |
| 167 | +20000)` from it, and auto-compacts 13,000 tokens below that. So: |
| 168 | + |
| 169 | +```text |
| 170 | +compacts at = window − headroom − min(max_output, 20000) − 13000 |
| 171 | +hard stop at = window − headroom − min(max_output, 20000) − 3000 |
| 172 | +``` |
| 173 | + |
| 174 | +Two things follow: |
| 175 | + |
| 176 | +1. **Do not subtract the output reservation yourself.** Claude Code already |
| 177 | + does. Subtracting it again — which is what this wrapper and the AI Tools page |
| 178 | + used to do — throws away 20,000 tokens of context for nothing. On a |
| 179 | + 111,200-token window, the old wrapper (which also reserved 32,768 for output |
| 180 | + and took 8,192 of headroom) compacted at 37,240 tokens; the same window now |
| 181 | + compacts at 75,976. |
| 182 | +2. **`CLAUDE_CODE_MAX_OUTPUT_TOKENS` above 20,000 buys nothing.** The |
| 183 | + reservation is capped there regardless, so a larger value only inflates the |
| 184 | + `max_tokens` on the wire. The wrapper sets exactly 20,000. |
| 185 | + |
| 186 | +**What happens when a session hits the limit?** In order: at |
| 187 | +`window − reserve − 13000` Claude Code compacts the conversation by itself and |
| 188 | +carries on. If a single turn grows past `window − reserve − 3000` it refuses to |
| 189 | +send and asks for a `/compact` instead. Neither is an error the user has to |
| 190 | +recover from — the failure mode this replaces was a 400 from vLLM mid-turn. |
| 191 | + |
| 192 | +The "auto-compact fires at ~60%" effect that started this work is these two |
| 193 | +fixed deductions — 33,000 tokens in total — as a share of a window that was |
| 194 | +already too small. It is not a percentage, and there is no knob to raise it: |
| 195 | +`CLAUDE_AUTOCOMPACT_PCT_OVERRIDE` exists but is clamped by |
| 196 | +`min(window × pct, window − 13000)`, so it can only compact *earlier*. The only |
| 197 | +lever is the window itself, which is what §2 and §3 are for. |
| 198 | + |
| 199 | +One caveat the wrapper warns about: a model id starting with `claude-` or |
| 200 | +containing `[1m]` is resolved to one of Claude Code's own models, and |
| 201 | +`CLAUDE_CODE_MAX_CONTEXT_TOKENS` is ignored for it. `DISABLE_COMPACT=1` forces |
| 202 | +the window through, at the cost of auto-compaction. |
| 203 | + |
| 204 | +Useful commands: |
| 205 | + |
| 206 | +```bash |
| 207 | +claude-logos --check connection, model and how much room a session would get |
| 208 | +claude-logos --update replace the wrapper with the current one |
| 209 | +claude-logos --uninstall remove the wrapper, its config and its key |
| 210 | +claude-logos --help this, then claude's own help |
| 211 | +``` |
| 212 | + |
| 213 | +#### Revisions |
| 214 | + |
| 215 | +`CLAUDE_LOGOS_VERSION` near the top of the script is a monotonic integer — bump it |
| 216 | +in the same commit as any change installed copies should pick up, and keep the |
| 217 | +`$ClaudeLogosVersion` in `claude-logos.ps1` in step. It is the only place the |
| 218 | +revision lives: Logos serves the current wrapper at the same URL an installed copy |
| 219 | +came from, so there is no second file to keep in sync and no way for the two to |
| 220 | +disagree. |
| 221 | + |
| 222 | +Installed copies **never update themselves.** At most once a day the wrapper |
| 223 | +fetches that URL in the background and records the revision it found; the next |
| 224 | +start compares it and, if a newer one exists, prints the one command that replaces |
| 225 | +it. So the notice costs no startup time and appears one start after a release — |
| 226 | +soon enough for something the user then has to type anyway. |
| 227 | + |
| 228 | +`--update` replaces the script and nothing else. The key, config and settings |
| 229 | +layer stay as they are, so an update is not a re-setup and the AI Tools page does |
| 230 | +not have to be visited again. It validates before replacing: the download has to |
| 231 | +contain a revision line and has to parse, because otherwise a captive portal or a |
| 232 | +proxy error page would leave a working wrapper overwritten with HTML — and that |
| 233 | +file is the next thing the user runs. The replacement is a rename within one |
| 234 | +directory, so a still-running copy keeps reading the old inode and finishes |
| 235 | +normally. |
| 236 | + |
| 237 | +### OpenCode |
| 238 | + |
| 239 | +OpenCode reads its config once at startup and cannot re-read it, so the |
| 240 | +generated `opencode.json` states `max_model_len_overall` — the ceiling rather |
| 241 | +than a number that goes stale. Long conversations may be turned down when |
| 242 | +capacity is tight; the routing in §3 gives them the best available shot. |
| 243 | + |
| 244 | +## Troubleshooting |
| 245 | + |
| 246 | +| Symptom | Cause | |
| 247 | +| --- | --- | |
| 248 | +| Claude Code compacts far earlier than the window suggests | The output reservation is being subtracted twice, or the session is running on `guaranteed` while `available` is much larger. Check `claude-logos --check`. | |
| 249 | +| `maximum context length is N tokens` 400s | The request landed on a deployment narrower than the estimate expected — most likely one that reports no window. Switch that wrapper to `LOGOS_CONTEXT_SOURCE=guaranteed`. | |
| 250 | +| A model is never placed on a node | The placement floor cannot be met there. Look for the "no calibrated KV point serves the required minimum" line and lower `min_context_fraction` for that model in the worker's config.yml. | |
| 251 | +| `max_model_len` absent from `/v1/models` | Nothing reports a window: a cloud model, or a vLLM lane running at the model's native maximum, which the worker does not report. | |
0 commit comments