Skip to content

Commit c3fd6ea

Browse files
wasnertobiasclaude
andauthored
Logos: Guide the AI-tools setup step by step and size context windows dynamically (#775)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent cc0479b commit c3fd6ea

33 files changed

Lines changed: 4718 additions & 2138 deletions

logos/docs/context-windows.md

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
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

Comments
 (0)