Repository navigation
serve: model registry with hot-swap and per-request model logging - #74
hendrikras wants to merge 13 commits into
Conversation
Add a swappable model registry to so the model behind a stable route name can change without reconfiguring clients. - POST /v1/models/load swaps the loaded model; GET /v1/models lists the registry with loaded flags; generation requests are validated: unknown model is a 404, registered-but-not-loaded is a 409 pointing at the load endpoint instead of silently serving whatever is resident. - CLI: --models PATH[=ID] registers swappable containers (repeatable), --keep-previous keeps a replaced model resident instead of unloading it. - Swap opens the new container before closing the old one, so a failed load leaves the previous model serving; swap happens under the current engine's lock so a generation in flight finishes first. - Request log lines now name the model: the one that served a success, the one refused on a 409/404, the one a load switched to. New --no-log-requests silences request logging. - Tests over real sockets cover registry listing, swap, keep-previous, validation statuses, and the model names in the log; docs updated.
marcobambini
left a comment
There was a problem hiding this comment.
Thanks — this is a useful feature and it is carefully done: the swap-order
rollback, the real-socket tests and the docs are all in the right shape.
A few things need to change before it can go in, mostly around what happens
to requests that overlap a swap and around the RAM ceiling, plus one
behavior change that reaches servers that never use --models.
- Requests racing a swap (server.py
_chat/_completions): the
engine is picked before its lock is taken, and the per-model state
(chat_format,stop_tokens,default_thinking,model_info) is read
fromsrvrather than from a snapshot, so a request that overlaps a swap
can run on a closed engine, or on the new engine holding only the old
engine's lock, or on one model'swaste_ctxwith the other model's
format. Inline comments below. - RAM budget: opening the new container before closing the old one
with the startupengine_kwargsmeans that with the default
--budget 0both engines auto-size to ~3/4 of usable RAM, so a swap
briefly asks for ~1.5x the machine.ram_budget_bytesis meant to be a
hard ceiling for everything the process holds, and paging is the failure
mode this engine exists to avoid. - The 404 on an unknown
modelalso applies without--models:
servers that never opt into the registry start rejecting clients that
send a fixed model name, including the"model":"waste"example in
serve --help.
Smaller: --keep-previous's help text promises concurrent answering that
the code does not do; an explicit --usage hotlist is handed to every
container; a swap skips the startup diagnostics (chat_error in
particular).
🤖 Generated with Claude Code
| def _chat(self): | ||
| body = self._read_body() | ||
| srv = self.server | ||
| engine = srv.engine |
There was a problem hiding this comment.
engine is taken here, before any lock, and the swap only holds the
previous engine's lock. Two interleavings that break:
- Without
--keep-previous: a request readssrv.engine(A) → a swap takes
A's lock, opens B, closes A → the request takes A's lock and calls
state_reset()/generate()on a closed engine._check()raises
instead of crashing, but the client gets a 500 for a request that arrived
before the swap did. - In both modes: everything else the request reads —
srv.chat_format
(L593),srv.default_thinking(L591),srv.model_info(L597),
srv.new_parser(L603),srv.stop_tokens— comes fromsrvat the time
it is read, i.e. possibly from the model swapped in afterenginewas
taken. With--keep-previousthat means building an XTML prompt for a
container that speaks chat.json (or vice versa) and generating it on the
other model'swaste_ctx.
_detect() also rebinds those attributes one at a time, so even a request
that does not race the lock can read a half-updated set.
Suggestion: make the per-model state one immutable object (engine,
model_id, model_info, chat_format, stop_tokens, default_thinking, parser
factory, chat_error), have _detect() build a new one and assign it in a
single step, and have each request take a snapshot once, then lock
that snapshot's engine and re-check it is still current (or at least not
closed) after acquiring the lock — retrying on the new current model or
answering 409/503 if it moved.
| srv = self.server | ||
| self._log_model_from(body) # before check_model_request: a 404 or | ||
| # 409 line should still name the model that was refused | ||
| srv.check_model_request(body) |
There was a problem hiding this comment.
(Anchored here because L812 is outside the diff: this is about with srv.engine.lock: at L812–835 in _completions.)
Same race, a bit sharper: srv.engine is read once to take the lock and
again on L813, L814 and L835. A request waiting on A's lock across a swap
wakes up holding A's lock and calls state_reset(), tokenize() and
generate() on B, while another request may be holding B's lock. Only
generate() takes the engine's RLock internally, so the reset/tokenize/
generate sequence of two requests can interleave on one waste_ctx — the
cross-request contamination the comment in _chat describes. Take the
engine into a local once, as _chat does, and apply the same snapshot
fix.
|
|
||
| The old engine's lock is held across the whole swap, so a | ||
| generation in flight finishes before the slot moves under it, and | ||
| no request that took the old lock can find its engine closed. |
There was a problem hiding this comment.
"no request that took the old lock can find its engine closed" only holds
for a request that had already acquired the old lock. A request that
picked the old engine and is waiting for its lock gets it after the swap
has closed that engine (see L547). Either fix the race or correct the
docstring.
| models=parse_registry(args.models), | ||
| keep_previous=args.keep_previous, | ||
| engine_kwargs={ | ||
| "ram_budget_bytes": args.budget, |
There was a problem hiding this comment.
With the default --budget 0, each Engine() sizes itself to ~3/4 of
waste_usable_ram(). Because the new container is opened before the old
one is closed, a swap has two contexts each planned against the whole
machine, ~1.5x usable RAM, and a fully-resident container also starts its
preload thread in that window. docs/SERVE.md:353 says to "size
--budget so that moment fits", but nothing enforces it and the default
does not satisfy it.
Options, roughly in order of preference:
- Refuse
--modelswithout an explicit--budget, and check that
2x budget fits underwaste_usable_ram()(or plan the new container with
plan_memoryand refuse the swap with a 507/503 if it cannot fit next to
the current one). - With
--keep-previousthe sum is unbounded, not "both budgets": every
model ever loaded stays open. Cap the number of resident engines, or
count their budgets against a total and refuse the load that would
exceed it.
There was a problem hiding this comment.
Thanks, your suggestions have been implemented, however in addition I have opted to also add a new feature:
--auto-register (off by default, same posture as --allow-local-images) lets POST /v1/models/load take {"path": "..."} for a container it has never been told about, deriving the id the same way --models PATH[=ID] does.
register_path just edits the registry; load_model → check_room still does the real work, so the existing budget accounting, 507 refusal, and open-before-close rollback apply unchanged to a path loaded this way.
planned_budget now prices a no---budget load by floor_bytes rather than recommended_bytes, because that is the actual number that must fit when the engine is left to size itself to whatever the machine can spare.
I believe this achieves the same thing but is more flexible as it does not force a 2x budget at startup for models that might not be requested by a router during the session.
The two options both work side by side but we could also choose to drop one solution in favor of the other. Your choice.
|
|
||
| # ---- the model registry ---------------------------------------------- | ||
|
|
||
| def check_model_request(self, body: dict) -> None: |
There was a problem hiding this comment.
This runs even when no --models were given, so a single-container server
now 404s any model other than its id. Its id defaults to the file name
(k3.waste → k3), and many OpenAI clients send a fixed name they cannot
easily change; the serve --help example itself sends "model":"waste".
This is a breaking change for every existing deployment, not only for
those opting into the registry.
Suggestion: validate only when the registry has more than one entry, and
otherwise keep serving any name as today — or make strict validation its
own flag. Whichever way, it deserves a CHANGELOG line in the release that
ships it.
| "contexts need together is the sum of their " | ||
| "budgets, and on the machines this engine targets " | ||
| "that is the difference between working and " | ||
| "paging. Both models can then answer at once — " |
There was a problem hiding this comment.
"Both models can then answer at once" — but check_model_request answers
409 for any model that is not the current one, resident or not, and
load_model's docstring says so ("other resident models answer after the
next load names them, not before"). The help should describe what the
code does: switching back is a slot move instead of a reopen.
| # per-container facts are unknown until it is opened. | ||
| data = [api.model_object(srv.model_id, srv.started, | ||
| srv.model_info, loaded=True)] | ||
| data += [api.model_object(mid, srv.started, None, loaded=False) |
There was a problem hiding this comment.
With --keep-previous, a model that is still resident (in srv.engines)
is listed as "loaded": false, the same as one that was never opened. If
loaded means "current", call it that; if it means "resident", use
mid in srv.engines. The /v1/models/load response on L539 already
reports residency, so the two endpoints currently disagree.
| "direct_io": not args.no_direct_io, | ||
| "vision": args.vision, | ||
| "verify_records": args.verify, | ||
| "usage_path": args.usage, |
There was a problem hiding this comment.
--usage is a learned hotlist of one container's experts (default
<model>/usage.waste). When given explicitly, it is passed to every
container a swap opens, so GLM would be opened with K3's hotlist. With the
default (None) each container finds its own and this is fine. Either drop
usage_path from engine_kwargs, or refuse --usage together with
--models.
| # again on every load, and it keeps the old comment because every word | ||
| # of it still holds. | ||
|
|
||
| def _detect(self, engine: Engine, model_id: str) -> None: |
There was a problem hiding this comment.
At startup, __main__ reports what the container can do: the banner, the
chat_error warning ("/v1/chat/completions is unavailable for this
container"), the thinking/tools/images line. A swap re-runs detection but
reports none of it, so switching to a container without a usable chat
format produces 400s with no operator-visible note. At least log a line on
stderr per swap: which model, and chat_error if set.
…no swap A request used to read srv.engine before taking any lock, and read the other per-model facts (chat_format, model_info, stop_tokens, default_thinking, markers, chat_error) one attribute at a time. Two interleavings broke: a request granted the old engine's lock only after a swap had closed it called state_reset()/generate() on a dead ctx and answered 500; and a request could see the new engine with the old chat format — building an XTML prompt for a container that speaks chat.json — because _detect() re-bound the attributes one at a time under a lock the reader had not taken. The per-model facts are now one immutable ModelSlot (engine, model_id, model_info, markers, chat_format, chat_error, stop_tokens, default_thinking, plus the parser factory), built whole by ModelSlot.detect() and published in a single assignment. Each request takes current_slot() once, locks that snapshot's engine, and re-checks with check_engine() that the slot is still current before touching the engine — answering 409 model_switched with the new current model id otherwise. The same discipline covers /v1/completions, and the streaming/blocking tails report stats and model from the slot rather than re-reading the moving server mid-generation.
A swap opens the new container before closing the old one — the order that makes a failed open leave the server serving what it was serving — so two contexts are resident at once. docs/SERVE.md asked the operator to size --budget so that moment fits, and nothing checked it. With the default --budget 0 the moment was worse than "the sum of the two": each context sizes itself to up to 3/4 of waste_usable_ram (waste.h, waste_cfg.ram_budget_bytes), so a swap ran at ~1.5x what the process may use — a paging run, not a slow one. With --keep-previous the total is not two but every model ever loaded, which no startup check can price at all. - --models now requires an explicit --budget, and the server refuses to start unless 2 x budget fits, naming the largest budget that does; the same lines print under the startup banner and under --plan, which is where a budget gets chosen. - The resident set is counted at every load — each engine's declared budget, or the floor plus expert cache waste_memory_used reports for one that chose its own — and a load that would not fit is refused with 507 (insufficient_memory) *before* the container is opened: nothing is closed, nothing is half-loaded, the previous model keeps serving, and the message says what it needed next to what was already held. 507 rather than 503 because asking again cannot help. - A slot move to a model that is already resident is never refused: it allocates nothing. Evicting a resident model to make room is not done — changing what is resident behind a client's back is the failure --keep-previous exists to prevent. serve/api.py gains human_bytes, shared by the banner and the refusal, so the two cannot quote different figures for the same machine. A platform that will not report its RAM is warned about, not refused: a limit nobody can measure is not a limit. Tests: 9 new server tests (144, from 135) and tests/serve/test_main.py with 12 of its own — the arithmetic needs no container, no libwaste and no particular machine. Checked end to end against real containers too: the banner, /v1/models, a --keep-previous swap, and a 507 from the third resident at a declared budget of 24G on a 64G machine.
The registry made check_model_request strict for every deployment: with no --models the registry holds only the loaded model, so a request naming anything else was a 404 — breaking every client that sends a fixed model name it cannot easily change, including serve --help's own example of "model": "waste". The server's id defaults to the container's file name (k3.waste -> k3), so even the correct name was one most clients did not know. Validation now applies only when the registry names a swappable set — len(registry) > 1, which is what --models gives — and a single-container server answers any name with the one model it has, as it did before the registry existed. Strict validation is unchanged where it is wanted: a 404 for a name the registry does not know, a 409 for one it knows but has not loaded, on both chat completions and raw completions, with the model-before-shape ordering the OpenAI API uses. docs/SERVE.md no longer describes the 404 as the default behaviour, and the CHANGELOG carries the break-and-restore under Unreleased. Tests: the no-registry case is covered where it lives now (a foreign name served, shape validation first), the registry case keeps its 404/409 and ordering tests, and /v1/completions gets the coverage chat had. Suite is 149 server tests, all green.
With --keep-previous a model the server still holds an open waste_ctx
for was listed loaded:false on GET /v1/models and /v1/models/{id} -
indistinguishable from a container never opened - while POST
/v1/models/load counted it in its resident set and check_model_request
called it 'not loaded'. The flag is residency: mid in engines, the same
set the load response reports. The 409 for a resident-but-idle model
names residency too; the model_not_loaded type and status are unchanged.
The waste shape still travels only on the current entry - the per-model
facts move with the current slot and are re-derived when a swap makes a
resident model current again.
Refusing --usage when --models is provided rejects this invalid configuration upfront, explaining clearly that --usage is container-specific and cannot be shared across multiple models. At the same time, removing "usage_path": args.usage from engine_kwargs in serve/__main__.py ensures that swap operations always let each container find its own hotlist (the default None behavior), preventing any accidental propagation of a single hotlist to swappable models.
To ensure model swaps report container capabilities and any chat formatting limitations to the operator, we log a line to `sys.stderr` on each swap in `ChatServer.load_model`, reporting the model switched to and its `chat_error` when set.
|
Nice PR — the registry/404/409 semantics, the open-before-close rollback ordering, and the budget pre-check are all well shaped, and One thing I think should block merge: a lock-order inversion that wedges the whole server.
A Repro — deterministic, no timing race, on a clean clone of this PR head ( Start a thread that holds Suggested fix: make the swap use the same order as everything else — read the slot, release Regression check on the fix,
Identical, so the reorder costs nothing in the suite. Separately, those 2 failures are pre-existing on this branch and look like a real second defect introduced by Both Also FYI GitHub currently reports this unmergeable — content conflicts with main in Happy to send the lock-order fix as a PR against this branch if that's useful. |
|
Heads-up: the merge of What happened. The conflict resolution in Attribution (macOS arm64, suite run at each of the three revisions in a clean clone):
CI agrees: on 4d16785 the Fix. Deleting the duplicated 31-line block (keep main's Repro, no build needed: (and |
540ddc3 to
4d16785
Compare
the load endpoint may be given a pathname instead of (or alongside) a known id. The server derives the model's id from the path — the same convention --models PATH[=ID] already uses on the CLI (file name minus .waste, or an explicit id) — adds it to the registry
|
551a72f fixed the CI is still red on Attribution — copies of
The second copy arrived with Root cause. The block belongs to The same test names pass in their real home: Fix — delete the 14-line block at the end of Measured on a clean clone of The two failures in the before arm are exactly the two the CI job reports, and the after arm is clean with no remaining failures. (My earlier pass of this reused a prebuilt If you do want the swap-logging assertions covered for the GLM container too, that class's Boundary: local macOS arm64, CPU-only, self-built libwaste, no GPU and no real checkpoint. These are pure Python |
|
Thanks, the attribution table and the same binary before/after on a clean clone are exactly the right way to settle this, and the diagnosis is correct: the block belongs in TestRequestLogs, and the GLM class's setUp never gave its server a registry, so swap-a was always going to 404. I've verified against my tree: TestRequestLogs passes in its Where I diverted: instead of deleting lines 1870–1883, I completed the GLM class with what it was missing: a models={"swap-a": ...} registry entry, an engine factory, and the stderr-capture / logs() machinery TestRequestLogs already uses. That's the alternative your report names ("that class's setUp needs a registry that actually holds swap-a"),
If you'd rather have the deletion for a smaller diff, it's a contained revert of the GLM-class completion, happy to switch. |
|
Ran the new head (67d8338) rather than reading it. Results, then one real defect in The two load-log tests now pass, and I confirmed they are the ones that were failing. Same machine, same
Upstream CI agrees: 551a72f was red on linux-x86_64, linux-arm64, macos-arm64 and windows native; 67d8338 is 11/11 green. The four red runners I attributed earlier are resolved — that attribution is closed. Defect: a refused auto-register load leaves the id in the registry.
The consequence is not just a stale list entry. The id survives the refusal, so a later request can load it by id alone, with no One refused request permanently widens the id namespace the server accepts. That matters precisely because The 404-on-missing-file path is clean — Fix I measured (against 67d8338; whole serve suite mid = body.get("model")
path = body.get("path")
+ registered = None # set if this request added an id
...
+ known_before = set(srv.registry)
mid = srv.register_path(path, mid)
+ if mid not in known_before:
+ registered = mid
elif not isinstance(mid, str) or not mid:
raise api.APIError("'model' must be a non-empty string", param="model")
- previous = srv.load_model(mid)
+ try:
+ previous = srv.load_model(mid)
+ except BaseException:
+ if registered is not None:
+ srv.registry.pop(registered, None)
+ raise
Two boundaries on the above, so you can discount them correctly. First, I did not find that the leaked id was reachable from One note, not a defect: with the flag on, absent vs present paths are distinguishable (404 vs 507/200), so the endpoint can be used to test whether a path exists on the host. Off by default and behind an explicit flag, so I read that as inside the posture you documented — worth a line in the flag's help text rather than a code change, if you agree. On your question — keep both, and I would not drop Environment: macOS arm64, Python 3.14.7, |
serve: fix lock order inversion
|
Ran the lock-order fix at 1. Deadlock: fixed, measured. Same frozen probe as before, predictions written down before the clone:
The decisive number is the reader: it returns instantly while
On the retry loop: it re-runs only when a competing swap wins 2. The auto-register registry leak is still present at this head, and I can say so without re-running it. I extracted the is unchanged, that measurement carries forward unmodified — a refused load still widens the id namespace permanently, and step 2 ( One question, so I do not step on your branch: the PR head is on your fork, so I cannot push to it. If you want the rollback as a commit rather than a diff in a comment, say so and I will open a PR into this branch ( Environment: macOS arm64, Python 3.14.7, |
|
By all means, open the PR. I wont have time to push changes untill coming monday. |
register_path edits the registry before load_model prices anything, so a load that check_room refuses with 507 left its id behind. The id then loaded by name alone, with no path in the body -- bypassing the --auto-register gate on the one request that never passed it. Roll back exactly the id this request introduced (known_before diff), so a refused re-load of an operator's --models entry never deregisters it. Full serve suite: 406 tests, 0 failures.
|
Follow-up on the explicit hotlist isolation review: I independently reproduced a remaining CLI loophole on head 957ae5c. I have a local focused candidate extending the existing refusal to Publication is held: repository-wide lint/type gates remain red. Saved exact-base/candidate lint reports each contain 178 diagnostics; type reports are identical. I am not claiming a fully green repository or that all review items are resolved. No follow-up branch has been pushed. |
|
Do you require any action from my side to move this PR forward? |
|
No blocker from my side. Status at 957ae5c:
CI is 11/11 green at this head. |
Add a swappable model registry so the model behind a stable route name can change without reconfiguring clients.