* feat(studio): run chats in parallel in the Chat tab
New Chat used to cancel whatever the current conversation was generating.
It now leaves it running, like switching to the Train or Export tab: the
sidebar shows which chats are still going, and Stop is per conversation.
Plain `unsloth studio` launched llama-server with one decode slot, so the
admission queue serialised every chat regardless of what the UI did. Both
entry points now default to the same slot count as `unsloth studio run`.
A model swap still ends every running chat, since they all decode on one
llama-server. /load and /unload now refuse with 409 and name those chats
unless the caller passes force_cancel_active, and the UI asks first.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* fix(studio): scope the composer tool badge to its own conversation
The green "Running Python: ..." badge above the composer read a single
global store value, so one chat's tool call showed above every other
chat's composer, including a brand-new empty one. Its elapsed counter
also restarted at 0 on every thread switch, and a run ending anywhere
cleared the badge everywhere.
Key the status by thread and store the moment it started, so each
conversation shows only its own tool call and the counter resumes rather
than restarts. Also adds a test that every conversation gets its own
tool sandbox directory, which parallel tool calls depend on.
* Fix stalled tool calls while awaiting approval for PR #7455
Three problems, all from the approval prompt behaving as though only one
chat could ever run.
Arguments were not streamed for a gated call, so the chat stayed blank for
as long as the model took to write the payload, which for a large file is
minutes. Nothing runs before the decision either way, and the code is what
is being approved, so python and terminal now stream their card while
gated. render_html stays suppressed: its card renders the payload.
The status read "Running ..." with a climbing timer while the call had not
started. It now reports that it is waiting for approval, then switches to
running once allowed.
The admission lease was held across the wait, so four unanswered prompts
held all four decode slots and no other chat could start while llama-server
sat idle. A parked run keeps its lease but no longer counts against
capacity.
Measured with four prompts left open: every gated call streamed its code,
none reported running, and a fresh chat answered in 0.4s where it
previously waited 290s and never did.
* Fix duplicated and truncated tool cards for PR #7455
A gated tool call rendered two cards: the provisional one that streams the
arguments, plus a second one keyed by the approval id. Only the second ever
got its tool_end, so the first spun "Running" for the rest of the chat.
Reuse the open part when the approval prompt arrives.
The terminal card also showed nothing but a 60-char trigger label, so a long
heredoc read as no progress at all. It now renders the command the same way
the Python card renders its script, and neither is capped at 10k chars.
Both cells moved inside the collapsible, so one chevron hides the code with
the output and Copy / Download exist only while the card is open. A card
parked on the prompt says so instead of counting up "Running".
* Fix review findings on the parallel-chat gate for PR #7455
Backend:
- /unload rechecks active generations under the lifecycle gate, like /load,
and lets its 409 through the catch-all instead of rewriting it as a 500.
- /load gates only once _load_model_impl has decided this is a real reload,
so an Apply on the already-loaded model no longer refuses, and the retry it
asks for no longer cancels every chat before returning already_loaded.
- The direct /v1/responses stream registers in the cancel registry, so a
non-forced unload can no longer tear llama-server down under it.
- run_server defaults to the same slot count as the CLI. colab.py calls it
without the argument, so Colab was still serialising every chat.
Frontend:
- Cancelling a backgrounded chat aborts its own request rather than only
posting a cancel id, which is the only thing that ends an external-provider
or audio run.
- The model-swap dialog counts local runs only, and falls back to the backend
when this tab's map is empty, so a reload or a second tab still gets asked.
- Context usage and the diffusion canvas are scoped to the chat that produced
them; a compare row reads activity from its member threads.
Tests:
- The extracted-source cancel harnesses supply the active-generations module,
which the tracked-cancel class now depends on.
* Fix the swap confirmation scope and cancel timing for PR #7455
A forced load cancelled every chat before the model identifier, GPU selection,
training coexistence and download checks had run, so a load that then failed
those checks stopped the chats and replaced nothing. The refusal still happens
early, but the destructive cancel now sits immediately before the teardown it
is paying for, and rechecks under the gate like /unload does.
The swap dialog only reconciled with the backend when this tab looked idle, so
one local chat was enough to hide a second tab's runs. Confirming then sent
force_cancel_active, which cancels every backend run, including the ones the
dialog never mentioned. The backend snapshot is now merged in every time, so
the dialog names what will actually stop. External-provider runs are never
registered there, so the union stays local-only.
Also drops the active-generations docstring claim about restoring sidebar
spinners, which nothing consumes.
* Defer destructive cancels and track every local stream for PR #7455
/unload cancelled the running chats before it had resolved that it unloads
anything. A stale model_path, which a second tab produces routinely, killed
every chat and then no-opped, leaving the resident model up. It now refuses
early and cancels only at each teardown, matching /load.
The swap dialog also stopped every chat locally the moment the user confirmed,
which threw away the two-phase backend behaviour: a load that then failed
identifier resolution, GPU validation or the training guard had already
truncated the replies. The backend now owns the cancel.
Three local streams decoded on llama-server without registering, so a
non-forced unload counted zero generations and tore the server down mid
response: /v1/completions streaming, and the plain and server-tool Anthropic
streams, the first of which is the default /v1/messages path. Note this makes
a non-forced load return 409 during those runs rather than draining quietly,
the same trade the /v1/responses fix made.
The safetensors tool loop still announced a gated call as running while it
waited on a human; only the GGUF loop had been fixed. A source-level parity
test now pins both.
Also drops stopAllChatThreads, which has no callers left.
* Studio: close three load/unload gate races found in review
Re-check the in-flight load guard after the stop-running-chats confirm.
The confirm always GETs active-generations before its zero-running
early-out, so the guard no longer sits atomically ahead of the
reservation and two picks in that window both reached performLoad over
the same refs. ejectModel had the same shape and gets the same re-check.
Reject a sidecar swap immediately before the forced cancel in both load
branches. The previous check was back at the top of preflight, so an
install reserving during identifier resolution, the tier probe, the
training guard or the download check made the post-drain recheck 409 a
load whose chats had already been stopped.
Enter the Anthropic passthrough's cancel tracker inside its body
generator. It was entered eagerly and returned through
_sse_streaming_response, which sets no unstarted_cleanup, so a response
whose body never started left the run registered forever and 409'd every
later non-forced load and unload.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Trim comments across the files this PR touches
Tightens the comments and doc blocks in the backend, CLI, tests and frontend
files changed by this PR: collapses multi-line explanations to a single line
where they still read clearly, and drops the ones the code already says.
No code changes, verified by an AST comparison against the previous commit.
* Studio: defer the destructive cancel and close two gate gaps
Move the forced cancel behind every check that can still reject a swap.
The drain now runs first with the runs it is about to cancel discounted,
so it waits only for inference the cancel cannot end, then the sidecar
check decides, then the cancel fires, then a second drain lets those runs
unwind before teardown. A sidecar install reserving during the drain no
longer 409s a load whose chats have already been stopped.
Track the non-streaming /v1/completions proxy. It was the last local
decode path missing from active_generations, so an unload, which runs no
drain, tore llama-server down under it and force_cancel_active could not
signal it. It now uses the same tracked cancel event and dedicated client
as the OpenAI pass-through.
Skip the client's preliminary unload while chats are generating and let
/load evict at its own post-preflight point instead. Forwarding
force_cancel_active there truncated replies before identifier
resolution, the GPU and training guards and the download check had run.
Keep per-thread context usage so returning to a chat whose background run
finished restores its bar instead of leaving it blank until the next turn.
Make the running-flag clear run-specific. Every run without a resolved
thread id shares the "__default" key, so concurrent compare panes could
clear each other's flag and strand a live stop handle.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Studio: register the embeddings proxy with the swap gate
/v1/embeddings proxied straight through the pooled client with no tracked
cancel event, so it never appeared in active_generations. /unload runs no
idle drain, so a concurrent non-forced unload counted zero generations and
killed llama-server mid-request, and force_cancel_active had no event to
signal. Mirrors the completions proxy: tracked event, dedicated unpooled
client closed by a cancel/disconnect watcher, unregister in a nested
finally so a close failure cannot leave a phantom generation behind.
* Trim comments on the newest changes in this PR
Comments only, no code changes: shorten the ones added by the load-gate
ordering, embeddings and per-thread usage work down to the same density as
the rest of the diff.
* Studio: register the legacy generate stream with the swap gate
/generate/stream built a cancel event but never entered the tracker, so it
was invisible to active_generations. Being in the keep-warm middleware's
inference suffixes only covers /load, which drains; /unload does not, so a
non-forced unload passed the 409 gate and then blocked on the standard
backend's generation lock, and a forced swap had no event to signal.
Registered inside the body generator under a nested finally so a teardown
failure cannot skip the unregister.
The AST contract test asserted the cleanup finally by overwriting its flag
per Try node, so a nested try made the last one win. Accumulate instead,
which is what the existence claim meant.
* Studio: three more swap-gate gaps found in review
Register /audio/generate with the gate. TTS holds the model for the whole
request and /unload runs no drain, so unregistered a non-forced swap counted
zero generations and tore the model down mid-generation; the orchestrator
path only waits 15s for the generation lock, which real TTS exceeds. No
cancel keys: no backend takes a cancel_event for audio, so the event has no
observer and a forced swap still cannot interrupt audio already in flight.
Thread the tracked cancel event into the /v1/responses admission wait. It
was the only admission caller passing None, so a queued run could not be
reached by cancel_all() and a plain /inference/cancel could not stop it at
all. Same omission fixed at the upstream send there and on /v1/completions.
Let an unforced unload of a stale model path reach the no-op check. Before
this PR that request returned 200 and did nothing; the new gate refused it
with 409 for a request that reaches no teardown branch. Gate both refusal
passes on the disjunction of the route's own teardown conditions, including
not is_loaded, so a mid-load GGUF still refuses.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Studio: register the remaining non-streaming decode paths
stream defaults to false on all three of these, so they are the ordinary
shape of their routes, and each holds a local backend for the whole
request. /unload runs no idle drain, so with no registry entry a non-forced
swap counted zero generations and tore the backend down mid-request instead
of returning 409, and a forced one had no event to signal.
Non-streaming /v1/messages: all three helpers ran with an empty registry,
since only the streaming siblings were tracked. Registered at the call site
because the pass-through takes no cancel_event of its own, and with no
cancel keys, matching those siblings.
Non-streaming standard chat and audio-input chat: the trackers in this route
sit inside their `if payload.stream:` arms, so neither else branch was
covered. The GGUF sibling already registers its own non-streaming branch.
Each exit is in a finally on the branch's existing try, so the except arms
are covered too: a leaked entry 409s every later swap until restart.
* Studio: tighten the swap-gate comments
Comment-only pass over the newest swap-gate registrations: collapse the multi-line rationales in /unload, the legacy generate stream, audio generation and the non-streaming chat branches, and the matching test preambles, to the shortest form that still carries the reason. No code changes.
* Studio: stop the reselect dialog promising a stop that never happens
Picking an external provider leaves the local model resident and stops the
status poll mirroring it, so reselecting that model showed the stop-chats
dialog, and /load then answered already_loaded ahead of its cancel hook.
Confirmed with the live backend: the same pick with force_cancel_active set
still returned already_loaded and the chat kept streaming. Not stopping
those chats is right, since the load never interrupts them, so remove the
prompt rather than honour it. Blanket-skipping is unsafe, because the same
id and variant with one sampling setting changed is a real reload and 409s,
so the branch only fires when a status fetch confirms the resident
checkpoint and variant match, and then adopts it without calling /load.
Redact native model paths from the active-generations response. Registering
/generate/stream recorded backend.active_model_name verbatim, which is an
absolute path for a native local model, and this route is the only place
that serialises it. Redacting at the response covers every tracker rather
than the one that surfaced it.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Studio: keep hydrated context usage in the per-thread map
The history loader restores a saved conversation's usage through
setContextUsage only, and it runs once per mount, so switching away and
back left the bar blank for a hydrated chat even after the per-thread map
landed. setContextUsage now writes the value through to the visible
thread's own entry and clears that entry when passed null, which covers
both hydration call sites and any future writer.
* Studio: unblock load cancellation and share unresolved thread keys
Run the two stop-loading fast paths ahead of the unload route's pre-gate
refusal. _unload_may_evict returns True for exactly the model being
cancelled, so the refusal was blocking the branch that cancels a load which
has replaced nothing and can interrupt no chat. The client made that
unrecoverable: cancelLoading sends the unload without force, drops the
result, and its abort never reaches /load, which takes no signal, so the
load ran on and could later cancel those chats and swap the model. Nothing
else is exempted; an unload that would tear down a serving model matches
neither fast path and still 409s. The comment claiming the client lets that
409 surface is corrected, since it discards it.
Hold every owner behind a shared thread key. Runs with no resolved thread id
share "__default" (concurrent compare panes, since startCompare clears
activeThreadId), so a single owner slot let a second run replace the first's
token and then delete the shared entry while it was still generating, and
the server-cancel map lost the older handle the same way. Both now hold a
list, the running and local flags survive until the last owner clears, and
stopChatThread stops every handle under the key.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Studio: carry a confirmed swap into the sidecar install, key restored usage by thread
Picking a model that needs a newer transformers while chats generate raised the
"stop N chats" prompt, but the answer never reached the install that runs before
the load: /install-latest-transformers refused on those same chats and took no
force flag, so Retry hit the same 409 and nothing in the flow stopped them.
Carry force_cancel_active through the consent dialog into the installer. Only
the pre-gate fast path is skipped: the recheck under the lifecycle gate still
has to pass, so an unconfirmed caller is refused as before. The cancel runs last
inside the gate, after every check that can still reject the install, and the
drain behind it is bounded since it holds the gate and the sidecar reservation.
Also key restored context usage by the thread the loader read. history.load()
captures remoteId before two awaited round trips, so a switch inside that window
filed one thread's usage under another and setActiveThreadId kept re-applying it.
Preserve sibling owners when a run key is cleared without an owner: the image
rejection gate now uses its own token, and the reducer leaves owned runs alone.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Studio: bound the post-cancel drains, and make cancellation reach the paths that ignored it
A forced swap cancels the chats it interrupts, then waits for them to unwind.
That wait had no deadline while holding the lifecycle gate, and TTS on the
subprocess backend observes no cancel event at all, so one audio generation
could pin every load, unload and new request for its whole duration. Bound both
post-cancel drains. Pre-cancel drains stay unbounded: the swap can still be
refused there, so shortening them would weaken what they protect.
/unload had the opposite problem and no drain at all, cancelling and tearing
down on the next line, which turned a clean stream end into a dropped
connection. Give it the same bounded wait, gated on the cancel having cancelled
something so an idle Eject pays nothing.
Make the cancel actually land where it can. GGUF TTS now takes a cancel_event
and a watcher closes its client to break the blocking POST. The Anthropic
non-streaming pass-through did the same thing the completions and embeddings
paths used to: register with the gate, then run both POSTs on the pooled client
that cannot be closed. It now uses a per-request client like they do.
Also: park and unpark the admission queue the reservation actually holds, since
queues are keyed by base_url and a reload mints a new port; key tool output by
remoteId on both sides, so the first turn of a New Chat stops writing under one
key and reading another; and give tool status a run owner, so a finishing run
cannot blank the badge a concurrent one is still showing.
Clamp --parallel to 1 on a llama-server without --kv-unified. The new default of
4 would otherwise split -c four ways on such a build, quartering the context
window for a feature it cannot serve.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Studio: scope a chat's Stop to its own generation, and clear the way before a confirmed install
Safetensors generation is serialized on _gen_lock and the worker has a single
cancel event, so a chat still queued on that lock owns no generation. Its Stop
handler called reset_generation_state() anyway, which set the shared event and
ended whichever conversation was actually running. Parallel chats is what makes
that reachable.
_generate_inner now records its cancel_event as the current holder once it takes
the lock, and reset_generation_state drops a reset from anyone else. Every route
call site passes its own request event. A reset with no event stays global, so
unload and model switch cannot leave a generation alive, and a reset while
nothing runs still resets, so an error path before generation is not a no-op.
The other two backends take the argument too, or the standard one raises
TypeError on every cancel.
The sidecar install had the mirror of the /load ordering problem: it cancelled
the chats first and drained second, so an unrelated counted request the cancel
cannot reach (a count_tokens, say) was still there for the recheck, which then
refused an install that had already stopped every chat for nothing. Drain the
unreachable remainder first, discounting the registered chats, then cancel.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Studio: close the windows the previous round's fixes left open
Three follow-ups, two of them holes in the fixes just before them.
The worker claim went in after _send_cmd, so the command was already running
unclaimed and a queued chat's Stop in that window still reset it. Claim first,
with the send inside the same try, so a failed send releases it too.
Tool status kept one entry per key with an owner. That stops a foreign clear but
not an overwrite: under the shared unresolved-thread key the second run replaced
the first's entry, and its own clear then removed the only one while the first
tool was still running. Keep per-run entries and render the newest.
/unload gated its drain on having cancelled something, so a request that passed
the keep-warm middleware but had not reached its tracker yet was invisible to it
and the teardown landed on an already-admitted request. Drain on the middleware
count instead, which covers that window as well as the cancelled runs, then
re-cancel whatever registered while waiting. Bounded, not a refusal: an unload is
deliberate, and on expiry it proceeds exactly as before.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Trim the parallel-chats comments to their reasons
Compress the multi-line rationales added by this branch into shorter forms and drop
restatements of the code below them. The reasons behind the drain bounds, the deferred
cancel, the per-request generation ownership and the thread-scoped tool and usage keys
are kept, just said in fewer lines.
* Studio: own the worker per generation, and make a resumed chat requeue for its slot
Ownership was a single lock holder, so dispatched runs (compare mode bypasses
_gen_lock by design) never claimed it and the guard fell straight through to the
global reset: a Stop on one of them ended its siblings. Track the generations
actually running instead, claimed before the send and released in the same
finally on both paths. A reset still proceeds when nothing is running, so an
error path ahead of generation is not swallowed.
park() hands the freed slot to a waiter, so a chat resuming from a tool approval
could take it back while that waiter was still decoding, putting two holders on
a one-slot server and sending the resumed tool loop past the admission limit.
unpark_async waits for room; the plain unpark stays for a holder tearing down,
which will not decode again.
Audio only observed its cancel event on a forced swap. An explicit Stop just
aborts the fetch, and this route has no cancel id, so llama-server ran on to the
request timeout after the chat reported it stopped. Watch the disconnect.
Also read tool status by remoteId, matching the key the adapter writes and the
fix already made for tool output, and stop an unresolved run from writing its
usage into whichever conversation the user moved to.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Studio: let only the generation the worker is running speak for it, and hold a slot for a resuming chat
The ownership list recorded admission, but the subprocess runs generations one
at a time, so a dispatched request queued behind another counted as an owner and
its Stop signalled the shared cancel event, ending the request that was actually
running. Keep admission for release bookkeeping and gate ownership on execution
instead, promoted when the worker first answers that request. Nothing executing
still permits a reset, so an error path ahead of generation is not swallowed.
The worker has one cancel event and no per-request cancellation, so this decides
who may pull the lever rather than making the lever per-request.
A resuming chat also polled for a slot it could never see: release() grants to
the next waiter under the same lock, so later arrivals overtook an approved chat
indefinitely. A pending unpark now reserves the next slot and they queue behind
it.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Studio: cover the prefill window, and keep a first turn's tool output readable
Gating worker ownership on execution left the interval between the send and the
first response uncovered: nothing is executing then, and the empty case admitted
anyone, so a queued chat's Stop still ended the one in prefill. Split the empty
case. Nothing claimed at all still permits a reset, so an error path ahead of
generation is not swallowed; claimed but unanswered resolves to the oldest
claim, which is what a FIFO command queue is working on.
Putting both sides of the tool-output scope on remoteId left the first turn of a
New Chat writing under the unresolved scope for its whole life while the readers
recomputed the moment the autosave assigned an id, so the card blanked mid-run.
The readers now fall back to the unresolved scope, which only an unpersisted
first turn can occupy.
* Studio: order the parked approvals, and tie a worker claim to its enqueue
The reservation added for admission fairness was a bare count, so every approved
holder counted against every other: park two chats, approve both, and once the
last decoder released, nothing could ever satisfy the check again. That is a
deadlock where the problem it fixed was only unfairness. Make it a FIFO ticket
so a pending unpark blocks the ones behind it and no others.
_owns_worker reads claim order to decide which request the worker is prefilling,
which only holds if claiming and enqueuing cannot interleave. Hold one lock
across both on the dispatched and the locked path.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Studio: follow a first turn's run when its thread gets an id, and key the denoising canvas per chat
A run started before its thread existed filed every handle under "__default". Nothing
moved them once autosave assigned the real id, so the sidebar row showed no spinner and
Stop could not reach the generation, which kept holding a slot.
adoptDefaultThreadRun re-keys the run maps onto the real id from the thread adapter's
initialize(), where the id first exists; anything already filed under that id wins, since
that is a later run. The adapter captures its key once at run start, so it now resolves
the live key per use through runKeyForOwner, looking its own serverCancel up in the owner
map. Without that the migrated entries are stranded and the spinner never clears.
The denoising canvas was one global slot, so two diffusion chats overwrote each other and
the ownership tag then hid the visible preview until that thread emitted again. It is now
activeDiffusionCanvasByThreadId, written and cleared per thread, and the frame no longer
carries a threadId of its own. The bubble reads threadListItem.remoteId, dropping the dead
threadListItem.id arm: the writer tags unstable_threadId, which is exactly remoteId.
Two existing backend tests needed the same treatment. _bare_orchestrator skips __init__, so
it now sets the claim bookkeeping the worker ownership check reads. The Anthropic
passthrough gate test anchored on comment prose that a rewrap had broken; it anchors on the
code instead.
* Studio: hand the worker over cleanly between generations, and stop unresolved runs sharing each other's state
Worker ownership moved off the consumer and onto the dispatcher. Consumers read their
mailbox whenever they get around to it, so a request whose gen_done had been routed still
owned the worker while the next one ran, and a late Stop for it cancelled that one. The
dispatcher is the only place responses arrive in the order the worker produced them: it
now retires a request at its terminal response and promotes the next one, and answering a
request makes it the sole executor, since the subprocess runs one generation at a time.
reserve()'s immediate path ignored the unpark tickets that _grant_waiters_locked already
honours, so a request arriving between a slot freeing and an approved chat's next poll
took it, repeatedly. It applies the same reservation now.
Three places let concurrent first turns share state through the "__default" key. Nothing
links a run filed there to the id its thread later receives, so rather than guess, each
now declines when the key is ambiguous: adoption only re-keys a lone run, the composer
badge only claims a lone status, and the tool-output fallback only applies to a thread
that is still running. That leaves two concurrent first turns where they were before
adoption existed instead of handing one thread the other's handles.
A first turn's usage was never filed, because its key stayed null for the whole run while
autosave moved activeThreadId to the real id, so the context bar went blank after the
first reply. It resolves the adopted key like the cleanup handles do.
Cancelling a forced load left the UI with no model: the previous one stays resident until
/load's teardown, and the cancel path cleared the checkpoint without rolling back. It now
resyncs from the backend, which is right whether or not the load got that far.
The sidecar install drain is weighted 1:4 rather than halved, total unchanged. Only the
second half benefits from patience, and cutting it short refused installs whose chats had
already been stopped for nothing.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Studio: give a first turn its real thread id before the run starts
A first turn filed every run handle under a shared unresolved key because
assistant-ui binds unstable_threadId before the thread is persisted. Two of them
overlapping there is unresolvable afterwards, and the last round's migration could
only decline rather than guess, which left neither sidebar row showing its run.
The id is available earlier than I claimed. append() already tracks
threadListItem.initialize() by the user message id, and createPersistedRunAdapter
already awaits that promise before invoking the adapter, so the thread is persisted
by the time the run begins. It was only being discarded: the tracked promise resolved
to void. It now resolves to the assigned id, and the wrapper hands it to the adapter
when assistant-ui had none. An id that is already set is never replaced, since that
would move a running chat's handles out from under the row watching them. The
existing unresolved-key guards stay as a safety net but should no longer carry weight.
The sidebar counted running thread ids rather than rows, so one compare conversation
read as two chats. It folds ids into rows through the same threadIds the row spinner
uses, and still counts a running id that matches no row.
_TrackedCancel always registered kind="chat", so an embeddings or raw completions
request appeared in the model-swap prompt as an unnamed conversation and confirming
cancelled it while calling it a chat. The non-conversation routes now pass their own
kind, and the prompt says "requests" whenever the snapshot is not all chats.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Studio: withhold the shared worker cancel from a request the worker has left
Moving ownership to the dispatcher fixed reset_generation_state, but the token loop
signals the shared worker event directly and did not carry the same rule. A dispatched
consumer runs with mark_started off and can still be draining tokens buffered before
its gen_done was routed, so stopping it there ended whichever request the worker had
started next.
It now signals only when _owns_worker agrees, the same predicate reset_generation_state
uses. The local drain and return are unconditional, since those touch nothing but this
stream. The remaining _cancel_generation callers are deliberately global: subprocess
shutdown, the pre-load kill and unload_model.
* Studio: add the AGPL-3.0 header to the first-turn identity test
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Studio: stop the dispatcher and a _gen_lock stream fighting over the response queue
Nothing stopped the dispatcher starting under a _gen_lock generation, so once compare
was opened while an ordinary chat was still streaming, both consumed _resp_queue and
whichever response the dispatcher took without a mailbox was dropped, gen_done included.
That chat truncated or hung. This PR is what makes it reachable, since navigating into
compare no longer ends the chat behind it.
Delaying the dispatcher would serialise compare behind whatever chat happens to be
streaming, so the direct readers get a mailbox instead. _direct_reader returns a reader,
a cancel drain and a release, and files the mailbox under _direct_mailboxes rather than
_mailboxes, which means "compare requests are in flight" to the unload and distributed
paths and must not count an ordinary chat.
Both directions close. The dispatcher finds the direct reader's mailbox instead of
dropping. And this reader can already be blocked on the queue when a compare request's
dispatcher starts, so a response that is not ours goes to its own mailbox rather than
being consumed, which would have corrupted the chat and hung the pane. All three
_gen_lock readers use it, and the cancel drain goes through it too.
The sidebar's return target still picked a raw pane id while the count grouped by row,
and /chat addresses compare with `compare`, not `thread`. It resolves through the same
items now, so a running compare row returns to its pair.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Studio: keep worker ownership honest across audio, API traffic and a replaced worker
The audio-input send got a mailbox last round but stayed unclaimed, so a compare request
queued behind it looked like the oldest owner and stopping that queued request signalled
the shared event into the audio chat. It claims under the send lock and releases in the
finally, like _generate_inner.
Ownership is keyed on cancel-event identity with nothing tying it to a worker generation,
so a consumer still blocked on its mailbox when the process was replaced stayed recorded
as the executor, and a generation on the fresh worker could not be stopped.
_shutdown_subprocess clears that state once the process is confirmed dead, mailboxes
included: nothing routes to them again, and a stale one reads as compare activity to the
unload path. Not on the survived-SIGKILL path, which keeps its handle on purpose.
The four public /v1/messages trackers were registering as chats. The distinction is a
Studio thread, not the protocol, and those branches already say "No thread_id: public API
surface" while the Studio path passes payload.thread_id separately. They carry their own
kind now, so the swap prompt stops calling an external request a chat.
The swap confirmation still counted raw pane ids, so a compare conversation asked to stop
two chats and listed its title twice. It folds panes onto pairId and lowers the count by
what it collapsed, leaving a first turn the backend can count but not name.
Deep Research set runningByThreadId but registered no server-cancel handle, and that map
is how Stop, archive and delete reach a thread that is no longer active. Leaving the
outgoing thread running is this PR's doing, so the run was left unreachable while its
supervisor kept working against a conversation the user could delete.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Studio: tighten the parallel-chats comments
* Studio: replay a Deep Research stop that arrived before the run existed
The handle is registered before createResearchRun resolves because the thread can be
stopped while that request is in flight, but it had no id to act on and dropped the stop.
The supervisor then followed a run the user had already stopped, archived or deleted.
It latches instead: a stop with no id yet sets a flag, and the adapter replays it against
the id the moment creation returns rather than starting to follow.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Studio: fix worker ownership on a raced reroute, and the stop-chats prompt
Four review findings on the parallel-chats work, all reproduced first.
- _direct_reader hands a foreign response to its own mailbox, but skipped the
ownership move the dispatcher makes. A _gen_lock reader already blocked on
resp_queue can beat the compare dispatcher to that request's first response,
and the compare consumer opts out of marking, so nothing promoted it: the
direct request stayed the recorded executor, its late reset cancelled the
compare generation, and the compare chat's own Stop was ignored.
- A chat stopped while queued on _gen_lock was still claimed and sent once the
lock freed. Cancellation is only checked on a token, so a long prefill, or a
generation reaching gen_done without one, occupied the worker after Stop.
Same hole in the audio-input path, which shares the lock.
- The stop-chats prompt counted generation handles, not conversations. One chat
holds several while a tool continuation registers its next leg before the
previous unwinds, so it offered to stop two chats and listed one title.
- Ejecting a model confirms through that dialog, which told the user
"Unloading the model reloads the model" and offered "Stop and reload".
Confirming calls /unload and leaves nothing loaded.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Studio: name the TTS run's thread so the stop prompt counts it once
The audio branch registers its run locally under the thread key but sent no
thread_id, so the backend tracker filed the same generation under no thread.
The stop-chats prompt then had a named local run and an unnamed backend one and,
since e8e7594 started adding unnamed entries to the named ones, counted a single
TTS chat as two requests. The backend already reads payload.thread_id, so
sending it lines both registries up on the same run.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
---------
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2635 lines
94 KiB
Python
2635 lines
94 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
"""Parallel chats: the active-generation registry and the model-swap gate.
|
|
|
|
A load/unload has to know which streaming chats it would interrupt. Everything
|
|
under test is a dict + threading.Lock, so this passes on every platform.
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import threading
|
|
|
|
import pytest
|
|
|
|
_backend = os.path.join(os.path.dirname(__file__), "..")
|
|
sys.path.insert(0, _backend)
|
|
|
|
from state import active_generations
|
|
|
|
|
|
@pytest.fixture(autouse = True)
|
|
def _clean_registry():
|
|
active_generations.reset_for_tests()
|
|
yield
|
|
active_generations.reset_for_tests()
|
|
|
|
|
|
# ── registry ──────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_registry_starts_empty():
|
|
assert active_generations.count() == 0
|
|
assert active_generations.snapshot() == []
|
|
assert active_generations.active_thread_ids() == []
|
|
|
|
|
|
def test_entry_lives_only_for_the_block():
|
|
ev = threading.Event()
|
|
with active_generations.ActiveGeneration(ev, thread_id = "t1", model = "m"):
|
|
assert active_generations.count() == 1
|
|
assert active_generations.active_thread_ids() == ["t1"]
|
|
assert active_generations.count() == 0
|
|
assert active_generations.active_thread_ids() == []
|
|
|
|
|
|
def test_entry_is_removed_even_when_the_block_raises():
|
|
ev = threading.Event()
|
|
with pytest.raises(RuntimeError):
|
|
with active_generations.ActiveGeneration(ev, thread_id = "t1"):
|
|
raise RuntimeError("stream blew up")
|
|
assert active_generations.count() == 0
|
|
|
|
|
|
def test_overlapping_runs_on_one_thread_both_register():
|
|
# A tool continuation registers its next leg before the previous unwinds.
|
|
a, b = threading.Event(), threading.Event()
|
|
with active_generations.ActiveGeneration(a, thread_id = "t1"):
|
|
with active_generations.ActiveGeneration(b, thread_id = "t1"):
|
|
assert active_generations.count() == 2
|
|
assert active_generations.active_thread_ids() == ["t1"]
|
|
assert active_generations.count() == 1
|
|
assert active_generations.count() == 0
|
|
|
|
|
|
def test_snapshot_is_json_safe_and_ordered_by_start():
|
|
a, b = threading.Event(), threading.Event()
|
|
with active_generations.ActiveGeneration(a, thread_id = "first", model = "m1"):
|
|
with active_generations.ActiveGeneration(b, thread_id = "second", model = "m2"):
|
|
snap = active_generations.snapshot()
|
|
assert [e["thread_id"] for e in snap] == ["first", "second"]
|
|
# The threading.Event must not leak into an HTTP response body.
|
|
assert all("event" not in e for e in snap)
|
|
assert {"handle", "thread_id", "model", "kind", "started_at"} == set(snap[0])
|
|
|
|
|
|
def test_thread_ids_are_deduped_and_skip_unnamed_runs():
|
|
a, b, c = threading.Event(), threading.Event(), threading.Event()
|
|
with active_generations.ActiveGeneration(a, thread_id = "t1"):
|
|
with active_generations.ActiveGeneration(b, thread_id = "t1"):
|
|
# A brand-new chat whose first turn races persistence has no id yet.
|
|
with active_generations.ActiveGeneration(c, thread_id = None):
|
|
assert active_generations.active_thread_ids() == ["t1"]
|
|
assert active_generations.count() == 3
|
|
|
|
|
|
# ── cancellation ──────────────────────────────────────────────────────
|
|
|
|
|
|
def test_cancel_all_sets_every_event():
|
|
a, b = threading.Event(), threading.Event()
|
|
with active_generations.ActiveGeneration(a, thread_id = "t1"):
|
|
with active_generations.ActiveGeneration(b, thread_id = "t2"):
|
|
assert active_generations.cancel_all() == 2
|
|
assert a.is_set() and b.is_set()
|
|
|
|
|
|
def test_cancel_all_on_an_empty_registry_is_a_no_op():
|
|
assert active_generations.cancel_all() == 0
|
|
|
|
|
|
def test_cancel_thread_leaves_siblings_alone():
|
|
# Per-thread Stop: the rest keep generating, llama-server is untouched.
|
|
a, b = threading.Event(), threading.Event()
|
|
with active_generations.ActiveGeneration(a, thread_id = "t1"):
|
|
with active_generations.ActiveGeneration(b, thread_id = "t2"):
|
|
assert active_generations.cancel_thread("t1") == 1
|
|
assert a.is_set()
|
|
assert not b.is_set()
|
|
|
|
|
|
def test_cancel_thread_with_no_match_is_a_no_op():
|
|
a = threading.Event()
|
|
with active_generations.ActiveGeneration(a, thread_id = "t1"):
|
|
assert active_generations.cancel_thread("nope") == 0
|
|
assert active_generations.cancel_thread("") == 0
|
|
assert not a.is_set()
|
|
|
|
|
|
def test_cancel_does_not_unregister_entries():
|
|
# __exit__ owns removal, so a generation mid-cleanup is not lost.
|
|
a = threading.Event()
|
|
with active_generations.ActiveGeneration(a, thread_id = "t1"):
|
|
active_generations.cancel_all()
|
|
assert active_generations.count() == 1
|
|
|
|
|
|
# ── concurrency ───────────────────────────────────────────────────────
|
|
|
|
|
|
def test_registry_survives_concurrent_register_unregister():
|
|
errors: list[BaseException] = []
|
|
barrier = threading.Barrier(8)
|
|
|
|
def worker(i: int) -> None:
|
|
try:
|
|
barrier.wait(timeout = 10)
|
|
for _ in range(50):
|
|
with active_generations.ActiveGeneration(threading.Event(), thread_id = f"t{i}"):
|
|
active_generations.snapshot()
|
|
except BaseException as exc: # noqa: BLE001 - surfaced via assert below
|
|
errors.append(exc)
|
|
|
|
threads = [threading.Thread(target = worker, args = (i,)) for i in range(8)]
|
|
for t in threads:
|
|
t.start()
|
|
for t in threads:
|
|
t.join(timeout = 30)
|
|
|
|
assert errors == []
|
|
assert active_generations.count() == 0
|
|
|
|
|
|
# ── the model-swap gate ───────────────────────────────────────────────
|
|
|
|
|
|
# The gate lives in routes.inference, which pulls the whole inference stack.
|
|
def _route_gate():
|
|
pytest.importorskip("fastapi", reason = "inference stack not installed")
|
|
routes_inference = pytest.importorskip(
|
|
"routes.inference", reason = "inference stack not installed"
|
|
)
|
|
return routes_inference._raise_or_cancel_active_generations
|
|
|
|
|
|
@pytest.fixture
|
|
def gate():
|
|
return _route_gate()
|
|
|
|
|
|
def test_gate_allows_a_swap_when_nothing_is_generating(gate):
|
|
assert gate(force = False, action = "Loading a model") == 0
|
|
|
|
|
|
def test_gate_refuses_with_409_and_names_the_chats(gate):
|
|
from fastapi import HTTPException
|
|
|
|
a, b = threading.Event(), threading.Event()
|
|
with active_generations.ActiveGeneration(a, thread_id = "t1"):
|
|
with active_generations.ActiveGeneration(b, thread_id = "t2"):
|
|
with pytest.raises(HTTPException) as exc:
|
|
gate(force = False, action = "Loading a model")
|
|
assert exc.value.status_code == 409
|
|
detail = exc.value.detail
|
|
assert detail["error"] == "active_generations"
|
|
assert detail["running"] == 2
|
|
assert detail["thread_ids"] == ["t1", "t2"]
|
|
# Refusing must not cancel anything.
|
|
assert not a.is_set() and not b.is_set()
|
|
|
|
|
|
def test_gate_message_is_singular_for_one_chat(gate):
|
|
from fastapi import HTTPException
|
|
|
|
with active_generations.ActiveGeneration(threading.Event(), thread_id = "t1"):
|
|
with pytest.raises(HTTPException) as exc:
|
|
gate(force = False, action = "Unloading the model")
|
|
message = exc.value.detail["message"]
|
|
assert "1 chat that is still generating" in message
|
|
assert "Unloading the model" in message
|
|
|
|
|
|
def test_gate_force_cancels_and_returns_the_count(gate):
|
|
a, b = threading.Event(), threading.Event()
|
|
with active_generations.ActiveGeneration(a, thread_id = "t1"):
|
|
with active_generations.ActiveGeneration(b, thread_id = "t2"):
|
|
assert gate(force = True, action = "Loading a model") == 2
|
|
assert a.is_set() and b.is_set()
|
|
|
|
|
|
def test_gate_force_with_nothing_running_is_a_no_op(gate):
|
|
assert gate(force = True, action = "Loading a model") == 0
|
|
|
|
|
|
# ── the route wiring ──────────────────────────────────────────────────
|
|
|
|
|
|
def test_tracked_cancel_registers_the_thread_for_its_block():
|
|
# The single place a generation is recorded, so every streaming path gets it.
|
|
_route_gate()
|
|
from routes.inference import _TrackedCancel
|
|
|
|
ev = threading.Event()
|
|
tracker = _TrackedCancel(ev, "cancel-1", thread_id = "t1", model = "m")
|
|
tracker.__enter__()
|
|
try:
|
|
assert active_generations.active_thread_ids() == ["t1"]
|
|
assert active_generations.snapshot()[0]["model"] == "m"
|
|
finally:
|
|
tracker.__exit__(None, None, None)
|
|
assert active_generations.count() == 0
|
|
|
|
|
|
def test_tracked_cancel_shares_its_event_with_the_registry():
|
|
# Reusing the per-run event is what keeps a forced reload off llama-server.
|
|
_route_gate()
|
|
from routes.inference import _TrackedCancel
|
|
|
|
ev = threading.Event()
|
|
tracker = _TrackedCancel(ev, "cancel-1", thread_id = "t1")
|
|
tracker.__enter__()
|
|
try:
|
|
active_generations.cancel_all()
|
|
assert ev.is_set()
|
|
finally:
|
|
tracker.__exit__(None, None, None)
|
|
|
|
|
|
def _stub_load_route(monkeypatch, *, active_model_name):
|
|
"""Point POST /load at an in-memory safetensors backend.
|
|
|
|
active_model_name == the requested path makes the request idempotent, so
|
|
_load_model_impl takes its already_loaded fast return.
|
|
"""
|
|
from types import SimpleNamespace
|
|
|
|
import routes.inference as inf_mod
|
|
|
|
monkeypatch.setattr(inf_mod, "_raise_if_sidecar_swap_in_progress", lambda: None)
|
|
monkeypatch.setattr(inf_mod, "validate_extra_args", lambda args: [])
|
|
monkeypatch.setattr(
|
|
inf_mod,
|
|
"resolve_effective_chat_template_override",
|
|
lambda model_identifier = None, user_override = None: None,
|
|
)
|
|
monkeypatch.setattr(inf_mod, "load_inference_config", lambda name: {})
|
|
monkeypatch.setattr(
|
|
inf_mod,
|
|
"_detect_safetensors_features",
|
|
lambda backend, template, tools = None: {
|
|
"supports_reasoning": False,
|
|
"reasoning_style": "enable_thinking",
|
|
"reasoning_effort_levels": [],
|
|
"reasoning_always_on": False,
|
|
"supports_preserve_thinking": False,
|
|
"supports_tools": False,
|
|
},
|
|
)
|
|
monkeypatch.setattr(inf_mod, "_resolve_loaded_trust_remote_code", lambda *a, **k: False)
|
|
monkeypatch.setattr(
|
|
inf_mod,
|
|
"get_inference_backend",
|
|
lambda: SimpleNamespace(active_model_name = active_model_name, models = {}),
|
|
)
|
|
monkeypatch.setattr(
|
|
inf_mod,
|
|
"get_llama_cpp_backend",
|
|
lambda: SimpleNamespace(is_loaded = False, hf_variant = None, model_identifier = None),
|
|
)
|
|
return inf_mod
|
|
|
|
|
|
def test_idempotent_load_neither_refuses_nor_cancels_running_chats(monkeypatch):
|
|
# Re-applying the resident model hits already_loaded: no llama-server touch, no 409, no stopped chats.
|
|
_route_gate()
|
|
import asyncio
|
|
|
|
from models.inference import LoadRequest
|
|
|
|
inf_mod = _stub_load_route(monkeypatch, active_model_name = "org/A")
|
|
|
|
for force in (False, True):
|
|
ev = threading.Event()
|
|
with active_generations.ActiveGeneration(ev, thread_id = "t1"):
|
|
response = asyncio.run(
|
|
inf_mod.load_model(
|
|
LoadRequest(model_path = "org/A", force_cancel_active = force),
|
|
object(),
|
|
"tester",
|
|
)
|
|
)
|
|
assert response.status == "already_loaded"
|
|
assert not ev.is_set()
|
|
|
|
|
|
def test_a_real_reload_still_refuses_while_chats_stream(monkeypatch):
|
|
# A load that would really replace the model still 409s and names the chats.
|
|
_route_gate()
|
|
import asyncio
|
|
|
|
from fastapi import HTTPException
|
|
|
|
from models.inference import LoadRequest
|
|
|
|
inf_mod = _stub_load_route(monkeypatch, active_model_name = "org/OTHER")
|
|
|
|
ev = threading.Event()
|
|
with active_generations.ActiveGeneration(ev, thread_id = "t1"):
|
|
with pytest.raises(HTTPException) as exc:
|
|
asyncio.run(inf_mod.load_model(LoadRequest(model_path = "org/A"), object(), "tester"))
|
|
assert exc.value.status_code == 409
|
|
assert exc.value.detail["thread_ids"] == ["t1"]
|
|
assert not ev.is_set()
|
|
|
|
|
|
def test_a_forced_load_that_fails_preflight_leaves_the_chats_alone(monkeypatch):
|
|
# Preflight can still reject after the user confirms, so cancelling first ends chats for nothing.
|
|
_route_gate()
|
|
import asyncio
|
|
import contextlib
|
|
|
|
from fastapi import HTTPException
|
|
|
|
from models.inference import LoadRequest
|
|
|
|
inf_mod = _stub_load_route(monkeypatch, active_model_name = "org/OTHER")
|
|
monkeypatch.setattr(inf_mod, "_hf_offline_if_dns_dead", contextlib.nullcontext)
|
|
# Stands in for any preflight refusal; a None here is the route's own 400.
|
|
monkeypatch.setattr(inf_mod.ModelConfig, "from_identifier", staticmethod(lambda **kwargs: None))
|
|
|
|
ev = threading.Event()
|
|
with active_generations.ActiveGeneration(ev, thread_id = "t1"):
|
|
with pytest.raises(HTTPException) as exc:
|
|
asyncio.run(
|
|
inf_mod.load_model(
|
|
LoadRequest(model_path = "org/A", force_cancel_active = True),
|
|
object(),
|
|
"tester",
|
|
)
|
|
)
|
|
# The load was rejected, so the chat must still be streaming.
|
|
assert not ev.is_set()
|
|
assert active_generations.count() == 1
|
|
assert exc.value.status_code == 400
|
|
|
|
|
|
def _stub_standard_load_route(monkeypatch):
|
|
"""Drive _load_model_impl down the Unsloth path as far as the pre-teardown drain."""
|
|
import contextlib
|
|
from types import SimpleNamespace
|
|
|
|
import routes.inference as inf_mod
|
|
|
|
real_sidecar_check = inf_mod._raise_if_sidecar_swap_in_progress
|
|
_stub_load_route(monkeypatch, active_model_name = "org/OTHER")
|
|
# _stub_load_route neutralises the sidecar guard; this test is about it.
|
|
monkeypatch.setattr(inf_mod, "_raise_if_sidecar_swap_in_progress", real_sidecar_check)
|
|
monkeypatch.setattr(inf_mod, "_hf_offline_if_dns_dead", contextlib.nullcontext)
|
|
monkeypatch.setattr(inf_mod, "_mlx_distributed_launch_detected", lambda: False)
|
|
monkeypatch.setattr(
|
|
inf_mod.ModelConfig,
|
|
"from_identifier",
|
|
staticmethod(
|
|
lambda **kwargs: SimpleNamespace(
|
|
is_gguf = False,
|
|
identifier = "org/A",
|
|
display_name = "A",
|
|
is_vision = False,
|
|
gguf_hf_repo = None,
|
|
gguf_variant = None,
|
|
)
|
|
),
|
|
)
|
|
monkeypatch.setattr(inf_mod, "_effective_load_in_4bit", lambda config, requested: False)
|
|
monkeypatch.setattr(inf_mod, "_resolve_inherited_extra_args", lambda *a, **k: None)
|
|
monkeypatch.setattr(inf_mod, "_guard_chat_load_against_training", lambda *a, **k: None)
|
|
return inf_mod
|
|
|
|
|
|
def test_a_sidecar_swap_reserved_during_the_drain_never_strands_cancelled_chats(monkeypatch):
|
|
# A sidecar install can reserve the swap window during the pre-teardown drain, so the recheck
|
|
# after it is the last rejection point and must precede the cancel, else chats die for nothing.
|
|
_route_gate()
|
|
import asyncio
|
|
import time
|
|
from types import SimpleNamespace
|
|
|
|
from fastapi import HTTPException
|
|
|
|
from core.inference import llama_keepwarm as kw
|
|
from models.inference import LoadRequest
|
|
|
|
import utils.transformers_version as tv
|
|
|
|
inf_mod = _stub_standard_load_route(monkeypatch)
|
|
reserved = {"v": False}
|
|
monkeypatch.setattr(tv, "sidecar_swap_in_progress", lambda: reserved["v"])
|
|
|
|
# Two tracked requests; the install reserves the window mid-drain when the uncancellable one ends.
|
|
monkeypatch.setattr(kw, "_inflight", 2)
|
|
|
|
def _installer():
|
|
time.sleep(0.10)
|
|
kw._inflight = 1 # the non-cancellable request finished ...
|
|
reserved["v"] = True # ... and an install reserved the swap window
|
|
time.sleep(0.35)
|
|
kw._inflight = 0 # the chat's own request drains last
|
|
|
|
thread = threading.Thread(target = _installer, daemon = True)
|
|
ev = threading.Event()
|
|
try:
|
|
with active_generations.ActiveGeneration(ev, thread_id = "t1"):
|
|
thread.start()
|
|
with pytest.raises(HTTPException) as exc:
|
|
asyncio.run(
|
|
inf_mod.load_model(
|
|
LoadRequest(model_path = "org/A", force_cancel_active = True),
|
|
SimpleNamespace(
|
|
app = SimpleNamespace(state = SimpleNamespace(llama_parallel_slots = 1))
|
|
),
|
|
"tester",
|
|
)
|
|
)
|
|
# Rejected, so the chat traded for a model it never got must still stream.
|
|
assert not ev.is_set()
|
|
assert active_generations.count() == 1
|
|
assert exc.value.status_code == 409
|
|
assert "transformers installation" in str(exc.value.detail)
|
|
finally:
|
|
thread.join(timeout = 5)
|
|
kw._inflight = 0
|
|
|
|
|
|
def _stub_unload_backends(monkeypatch, *, llama, backend):
|
|
"""Point the /unload route at in-memory backends."""
|
|
import routes.inference as inf_mod
|
|
from core.inference import llama_keepwarm as kw
|
|
|
|
monkeypatch.setattr(inf_mod, "get_llama_cpp_backend", lambda: llama)
|
|
monkeypatch.setattr(inf_mod, "get_inference_backend", lambda: backend)
|
|
monkeypatch.setattr(inf_mod, "is_registered_native_path_label", lambda *a: False)
|
|
monkeypatch.setattr(kw, "note_model_unloaded", lambda: None)
|
|
return inf_mod, kw
|
|
|
|
|
|
def test_unload_rechecks_active_generations_under_the_lifecycle_gate(monkeypatch):
|
|
# Without the recheck, a chat that starts while this queues on the gate is torn down mid-stream.
|
|
_route_gate()
|
|
import asyncio
|
|
from types import SimpleNamespace
|
|
|
|
from fastapi import HTTPException
|
|
|
|
from models.inference import UnloadRequest
|
|
|
|
torn_down: list[str] = []
|
|
inf_mod, kw = _stub_unload_backends(
|
|
monkeypatch,
|
|
llama = SimpleNamespace(
|
|
is_active = True,
|
|
is_loaded = True,
|
|
model_identifier = "org/A-GGUF",
|
|
unload_model = lambda: torn_down.append("gguf"),
|
|
),
|
|
backend = SimpleNamespace(
|
|
get_loading_model = lambda: None,
|
|
unload_model = lambda path: torn_down.append("unsloth"),
|
|
),
|
|
)
|
|
|
|
ev = threading.Event()
|
|
started = active_generations.ActiveGeneration(ev, thread_id = "t1")
|
|
|
|
async def drive():
|
|
# A load holds the lifecycle gate, so the unload queues behind it.
|
|
kw._lifecycle_lock.acquire()
|
|
task = asyncio.create_task(
|
|
inf_mod.unload_model(UnloadRequest(model_path = "org/A-GGUF"), "tester")
|
|
)
|
|
entered = False
|
|
try:
|
|
await asyncio.sleep(0.1) # the route is polling the gate
|
|
started.__enter__() # a chat starts in the meantime
|
|
entered = True
|
|
finally:
|
|
kw._lifecycle_lock.release()
|
|
try:
|
|
return await asyncio.wait_for(task, timeout = 5)
|
|
finally:
|
|
if entered:
|
|
started.__exit__(None, None, None)
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
asyncio.run(drive())
|
|
|
|
# 409, not the catch-all 500 the route wraps unexpected failures in.
|
|
assert exc.value.status_code == 409
|
|
assert exc.value.detail["error"] == "active_generations"
|
|
assert torn_down == []
|
|
assert not ev.is_set()
|
|
|
|
|
|
def _run_unload(
|
|
inf_mod,
|
|
monkeypatch,
|
|
*,
|
|
loaded_gguf,
|
|
requested,
|
|
force,
|
|
torn_down,
|
|
unload_model = None,
|
|
):
|
|
"""Drive POST /unload against a backend pair with ``loaded_gguf`` resident.
|
|
|
|
``unload_model`` overrides the GGUF teardown so a caller can observe what the
|
|
world looked like at the moment of teardown, not just afterwards.
|
|
"""
|
|
import asyncio
|
|
from types import SimpleNamespace
|
|
|
|
from models.inference import UnloadRequest
|
|
|
|
_stub_unload_backends(
|
|
monkeypatch,
|
|
llama = SimpleNamespace(
|
|
is_active = True,
|
|
is_loaded = True,
|
|
model_identifier = loaded_gguf,
|
|
unload_model = unload_model or (lambda: torn_down.append("gguf")),
|
|
),
|
|
# Nothing on the standard backend: the GGUF above is what is resident.
|
|
backend = SimpleNamespace(
|
|
get_loading_model = lambda: None,
|
|
active_model_name = None,
|
|
models = {},
|
|
unload_model = lambda path: torn_down.append("unsloth"),
|
|
),
|
|
)
|
|
return asyncio.run(
|
|
inf_mod.unload_model(
|
|
UnloadRequest(model_path = requested, force_cancel_active = force), "tester"
|
|
)
|
|
)
|
|
|
|
|
|
def test_forced_unload_of_a_stale_model_path_leaves_the_chats_alone(monkeypatch):
|
|
# Eject naming a model another tab swapped out: a no-op success; cancelling first loses runs.
|
|
_route_gate()
|
|
import routes.inference as inf_mod
|
|
|
|
torn_down: list[str] = []
|
|
ev = threading.Event()
|
|
with active_generations.ActiveGeneration(ev, thread_id = "t1"):
|
|
response = _run_unload(
|
|
inf_mod,
|
|
monkeypatch,
|
|
loaded_gguf = "org/B-GGUF", # what the other tab actually loaded
|
|
requested = "org/A-GGUF", # this tab's stale idea of it
|
|
force = True,
|
|
torn_down = torn_down,
|
|
)
|
|
assert not ev.is_set()
|
|
assert active_generations.count() == 1
|
|
# The resident GGUF was never touched, so nothing was worth cancelling.
|
|
assert "gguf" not in torn_down
|
|
assert response.status == "unloaded"
|
|
|
|
|
|
def test_forced_unload_of_the_loaded_model_still_stops_its_chats(monkeypatch):
|
|
# A real unload must still cancel, or llama-server goes down mid-stream.
|
|
_route_gate()
|
|
import routes.inference as inf_mod
|
|
|
|
torn_down: list[str] = []
|
|
ev = threading.Event()
|
|
with active_generations.ActiveGeneration(ev, thread_id = "t1"):
|
|
response = _run_unload(
|
|
inf_mod,
|
|
monkeypatch,
|
|
loaded_gguf = "org/A-GGUF",
|
|
requested = "org/A-GGUF",
|
|
force = True,
|
|
torn_down = torn_down,
|
|
)
|
|
assert ev.is_set()
|
|
assert torn_down == ["gguf"]
|
|
assert response.status == "unloaded"
|
|
|
|
|
|
def test_forced_unload_lets_the_cancelled_chats_unwind_before_teardown(monkeypatch):
|
|
# /unload used to tear down right after the cancel, so a stream told to stop but not yet
|
|
# finished lost its server. Assert the count hits zero BEFORE unload_model runs.
|
|
_route_gate()
|
|
import core.inference.llama_keepwarm as keepwarm
|
|
import routes.inference as inf_mod
|
|
|
|
inflight = {"n": 1}
|
|
seen = {}
|
|
|
|
def _count(current_request_counted = True, *, include_pending = True):
|
|
# Unwinds one poll after the cancel, like a stream noticing its event.
|
|
if inflight["n"] > 0:
|
|
inflight["n"] -= 1
|
|
return inflight["n"]
|
|
|
|
monkeypatch.setattr(keepwarm, "other_inference_request_count", _count)
|
|
monkeypatch.setattr(inf_mod, "_switch_waiter_count", lambda: 0)
|
|
|
|
torn_down: list[str] = []
|
|
ev = threading.Event()
|
|
|
|
def _record_teardown():
|
|
seen["inflight_at_teardown"] = inflight["n"]
|
|
torn_down.append("gguf")
|
|
|
|
with active_generations.ActiveGeneration(ev, thread_id = "t1"):
|
|
response = _run_unload(
|
|
inf_mod,
|
|
monkeypatch,
|
|
loaded_gguf = "org/A-GGUF",
|
|
requested = "org/A-GGUF",
|
|
force = True,
|
|
torn_down = torn_down,
|
|
unload_model = _record_teardown,
|
|
)
|
|
assert ev.is_set()
|
|
|
|
assert torn_down == ["gguf"]
|
|
assert seen["inflight_at_teardown"] == 0
|
|
assert response.status == "unloaded"
|
|
|
|
|
|
def test_unload_drains_on_the_middleware_count_not_just_the_registry(monkeypatch):
|
|
# A request past the middleware but not yet at its _TrackedCancel is counted but unregistered, so
|
|
# the drain reads the middleware count, not "did we cancel anything": one poll on a quiet server.
|
|
_route_gate()
|
|
import core.inference.llama_keepwarm as keepwarm
|
|
import routes.inference as inf_mod
|
|
|
|
polls = {"n": 0}
|
|
|
|
def _count(current_request_counted = True, *, include_pending = True):
|
|
polls["n"] += 1
|
|
return 0
|
|
|
|
monkeypatch.setattr(keepwarm, "other_inference_request_count", _count)
|
|
|
|
torn_down: list[str] = []
|
|
response = _run_unload(
|
|
inf_mod,
|
|
monkeypatch,
|
|
loaded_gguf = "org/A-GGUF",
|
|
requested = "org/A-GGUF",
|
|
force = True,
|
|
torn_down = torn_down,
|
|
)
|
|
assert torn_down == ["gguf"]
|
|
# Polled, but returned on the first read rather than waiting anything out.
|
|
assert polls["n"] == 1
|
|
assert response.status == "unloaded"
|
|
|
|
|
|
def test_unforced_unload_of_a_stale_model_path_is_still_a_no_op(monkeypatch):
|
|
# Same stale Eject unforced: it reaches no teardown, so refusing strands the stale tab's selection.
|
|
_route_gate()
|
|
import routes.inference as inf_mod
|
|
|
|
torn_down: list[str] = []
|
|
ev = threading.Event()
|
|
with active_generations.ActiveGeneration(ev, thread_id = "t1"):
|
|
response = _run_unload(
|
|
inf_mod,
|
|
monkeypatch,
|
|
loaded_gguf = "org/B-GGUF", # what the other tab actually loaded
|
|
requested = "org/A-GGUF", # this tab's stale idea of it
|
|
force = False,
|
|
torn_down = torn_down,
|
|
)
|
|
assert not ev.is_set()
|
|
assert active_generations.count() == 1
|
|
# The resident GGUF was untouched; only the standard backend's stale-path no-op ran.
|
|
assert torn_down == ["unsloth"]
|
|
assert response.status == "unloaded"
|
|
|
|
|
|
def test_unforced_unload_of_the_loaded_model_still_refuses_while_chats_stream(monkeypatch):
|
|
# The stale skip above must not disarm the gate for a real replacement.
|
|
_route_gate()
|
|
import routes.inference as inf_mod
|
|
|
|
from fastapi import HTTPException
|
|
|
|
torn_down: list[str] = []
|
|
ev = threading.Event()
|
|
with active_generations.ActiveGeneration(ev, thread_id = "t1"):
|
|
with pytest.raises(HTTPException) as exc:
|
|
_run_unload(
|
|
inf_mod,
|
|
monkeypatch,
|
|
loaded_gguf = "org/A-GGUF",
|
|
requested = "org/A-GGUF",
|
|
force = False,
|
|
torn_down = torn_down,
|
|
)
|
|
assert exc.value.status_code == 409
|
|
assert exc.value.detail["thread_ids"] == ["t1"]
|
|
assert torn_down == []
|
|
assert not ev.is_set()
|
|
|
|
|
|
def test_unforced_unload_still_refuses_while_a_gguf_load_is_in_flight(monkeypatch):
|
|
# A stale tab's Eject naming the PREVIOUS model while a different one loads. The GGUF branch
|
|
# evicts a live llama-server, so a chat on the previous model must get the 409, not be killed.
|
|
_route_gate()
|
|
import asyncio
|
|
from types import SimpleNamespace
|
|
|
|
from fastapi import HTTPException
|
|
|
|
from models.inference import UnloadRequest
|
|
|
|
torn_down: list[str] = []
|
|
inf_mod, _kw = _stub_unload_backends(
|
|
monkeypatch,
|
|
llama = SimpleNamespace(
|
|
is_active = True,
|
|
is_loaded = False, # spawned, health check not passed: mid-load
|
|
model_identifier = "org/B-GGUF",
|
|
unload_model = lambda: torn_down.append("gguf"),
|
|
),
|
|
backend = SimpleNamespace(
|
|
get_loading_model = lambda: None,
|
|
active_model_name = None,
|
|
models = {},
|
|
unload_model = lambda path: torn_down.append("unsloth"),
|
|
),
|
|
)
|
|
|
|
ev = threading.Event()
|
|
with active_generations.ActiveGeneration(ev, thread_id = "t1"):
|
|
with pytest.raises(HTTPException) as exc:
|
|
asyncio.run(
|
|
inf_mod.unload_model(
|
|
UnloadRequest(model_path = "org/A-GGUF", force_cancel_active = False),
|
|
"tester",
|
|
)
|
|
)
|
|
assert exc.value.status_code == 409
|
|
assert torn_down == []
|
|
assert not ev.is_set()
|
|
|
|
|
|
def test_cancelling_an_in_flight_standard_load_is_not_refused_by_the_chat_gate(monkeypatch):
|
|
# The real cancelLoading shape: unforced /unload naming the still-LOADING model. It replaces
|
|
# nothing, so it cannot interrupt a chat and must not 409 (the frontend would drop the error).
|
|
_route_gate()
|
|
import asyncio
|
|
from types import SimpleNamespace
|
|
|
|
from models.inference import UnloadRequest
|
|
|
|
cancelled: list[str] = []
|
|
torn_down: list[str] = []
|
|
inf_mod, _kw = _stub_unload_backends(
|
|
monkeypatch,
|
|
# Nothing on llama-server: the load in flight is a safetensors one.
|
|
llama = SimpleNamespace(
|
|
is_active = False,
|
|
is_loaded = False,
|
|
model_identifier = None,
|
|
unload_model = lambda: torn_down.append("gguf"),
|
|
),
|
|
backend = SimpleNamespace(
|
|
get_loading_model = lambda: "org/B",
|
|
cancel_load = lambda path: bool(cancelled.append(path)) or True,
|
|
active_model_name = None,
|
|
models = {},
|
|
unload_model = lambda path: torn_down.append("unsloth"),
|
|
),
|
|
)
|
|
|
|
ev = threading.Event()
|
|
with active_generations.ActiveGeneration(ev, thread_id = "t1"):
|
|
response = asyncio.run(
|
|
inf_mod.unload_model(
|
|
UnloadRequest(model_path = "org/B", force_cancel_active = False), "tester"
|
|
)
|
|
)
|
|
# The chat on the previous model is untouched: the load never reached it.
|
|
assert not ev.is_set()
|
|
assert active_generations.count() == 1
|
|
assert response.status == "unloaded"
|
|
assert cancelled == ["org/B"]
|
|
assert torn_down == []
|
|
|
|
|
|
def test_cancelling_an_in_flight_gguf_load_is_not_refused_by_the_chat_gate(monkeypatch):
|
|
# Same cancelLoading shape on the GGUF fast path: killing that child ends a load, not a chat.
|
|
_route_gate()
|
|
import asyncio
|
|
from types import SimpleNamespace
|
|
|
|
from models.inference import UnloadRequest
|
|
|
|
torn_down: list[str] = []
|
|
inf_mod, _kw = _stub_unload_backends(
|
|
monkeypatch,
|
|
llama = SimpleNamespace(
|
|
is_active = True,
|
|
is_loaded = False, # spawned, health check not passed: mid-load
|
|
model_identifier = "org/B-GGUF",
|
|
unload_model = lambda: torn_down.append("gguf"),
|
|
),
|
|
backend = SimpleNamespace(
|
|
get_loading_model = lambda: None,
|
|
active_model_name = None,
|
|
models = {},
|
|
unload_model = lambda path: torn_down.append("unsloth"),
|
|
),
|
|
)
|
|
|
|
ev = threading.Event()
|
|
with active_generations.ActiveGeneration(ev, thread_id = "t1"):
|
|
response = asyncio.run(
|
|
inf_mod.unload_model(
|
|
UnloadRequest(model_path = "org/B-GGUF", force_cancel_active = False), "tester"
|
|
)
|
|
)
|
|
assert not ev.is_set()
|
|
assert active_generations.count() == 1
|
|
assert response.status == "unloaded"
|
|
assert torn_down == ["gguf"]
|
|
|
|
|
|
def _install_responses_stream_mock(monkeypatch, chunks):
|
|
"""Point the direct /v1/responses GGUF pass-through at an in-process
|
|
llama-server. Mirrors the harness in test_responses_tool_passthrough.py."""
|
|
import json
|
|
from types import SimpleNamespace
|
|
|
|
import httpx
|
|
|
|
import routes.inference as inf_mod
|
|
|
|
def handler(request):
|
|
content = "".join(f"data: {json.dumps(chunk)}\n\n" for chunk in chunks)
|
|
content += "data: [DONE]\n\n"
|
|
return httpx.Response(
|
|
200,
|
|
content = content.encode(),
|
|
headers = {"content-type": "text/event-stream"},
|
|
)
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
real_async_client = httpx.AsyncClient
|
|
monkeypatch.setattr(
|
|
inf_mod.httpx,
|
|
"AsyncClient",
|
|
lambda *a, **kw: real_async_client(transport = transport, timeout = kw.get("timeout", 600)),
|
|
)
|
|
monkeypatch.setattr(
|
|
inf_mod,
|
|
"get_llama_cpp_backend",
|
|
lambda: SimpleNamespace(
|
|
is_loaded = True,
|
|
is_vision = False,
|
|
context_length = 4096,
|
|
base_url = "http://llama.test",
|
|
supports_reasoning = True,
|
|
reasoning_always_on = False,
|
|
_request_reasoning_kwargs = (
|
|
lambda enable_thinking = None, reasoning_effort = None, preserve_thinking = None: None
|
|
),
|
|
),
|
|
)
|
|
return inf_mod
|
|
|
|
|
|
class _NeverDisconnectedRequest:
|
|
async def is_disconnected(self):
|
|
return False
|
|
|
|
|
|
def test_direct_responses_stream_is_visible_to_the_swap_gate(monkeypatch):
|
|
# /v1/responses streams straight to llama-server; unregistered, a non-forced /unload tore it down.
|
|
_route_gate()
|
|
import asyncio
|
|
|
|
from models.inference import ChatMessage, ResponsesRequest
|
|
|
|
inf_mod = _install_responses_stream_mock(
|
|
monkeypatch, [{"choices": [{"delta": {"content": "33"}}]}]
|
|
)
|
|
payload = ResponsesRequest(input = "hi", stream = True, model = "org/M-GGUF")
|
|
messages = [ChatMessage(role = "user", content = "hi")]
|
|
seen = {}
|
|
|
|
async def run():
|
|
response = await inf_mod._responses_stream(payload, messages, _NeverDisconnectedRequest())
|
|
iterator = response.body_iterator
|
|
await iterator.__anext__()
|
|
seen["count"] = active_generations.count()
|
|
seen["snapshot"] = active_generations.snapshot()
|
|
async for _ in iterator:
|
|
pass
|
|
|
|
asyncio.run(run())
|
|
|
|
assert seen["count"] == 1
|
|
assert seen["snapshot"][0]["model"] == "org/M-GGUF"
|
|
# And it unregisters, or one Codex call would 409 every later reload.
|
|
assert active_generations.count() == 0
|
|
|
|
|
|
def test_forced_reload_stops_a_direct_responses_stream(monkeypatch):
|
|
# The registered event must be the one the stream watches, or a forced reload kills a live decode.
|
|
_route_gate()
|
|
import asyncio
|
|
|
|
from models.inference import ChatMessage, ResponsesRequest
|
|
|
|
inf_mod = _install_responses_stream_mock(
|
|
monkeypatch,
|
|
[
|
|
{"choices": [{"delta": {"content": "3"}}]},
|
|
{"choices": [{"delta": {"content": "3"}}]},
|
|
],
|
|
)
|
|
payload = ResponsesRequest(input = "hi", stream = True, model = "org/M-GGUF")
|
|
messages = [ChatMessage(role = "user", content = "hi")]
|
|
|
|
async def run():
|
|
response = await inf_mod._responses_stream(payload, messages, _NeverDisconnectedRequest())
|
|
iterator = response.body_iterator
|
|
chunks = [await iterator.__anext__()]
|
|
assert active_generations.cancel_all() == 1
|
|
async for chunk in iterator:
|
|
chunks.append(chunk)
|
|
return "".join(c.decode() if isinstance(c, bytes) else c for c in chunks)
|
|
|
|
body = asyncio.run(run())
|
|
|
|
# Cancelled mid-stream: the run ends without a completed envelope.
|
|
assert "response.completed" not in body
|
|
assert active_generations.count() == 0
|
|
|
|
|
|
def test_forced_reload_stops_a_responses_stream_still_queued_for_a_slot(monkeypatch):
|
|
# The run registers before it holds a decode slot, so cancel_all() must reach it while queued in
|
|
# admission; watching only the client socket lets it open a generation the swap already revoked.
|
|
_route_gate()
|
|
import asyncio
|
|
|
|
from core.inference import llama_admission
|
|
from models.inference import ChatMessage, ResponsesRequest
|
|
|
|
for name in (
|
|
llama_admission.ADMISSION_CONTROL_ENV,
|
|
llama_admission.ADMISSION_QUEUE_TIMEOUT_ENV,
|
|
llama_admission.ADMISSION_KEEPALIVE_INTERVAL_ENV,
|
|
llama_admission.ADMISSION_MAX_QUEUE_ENV,
|
|
):
|
|
monkeypatch.delenv(name, raising = False)
|
|
|
|
inf_mod = _install_responses_stream_mock(
|
|
monkeypatch, [{"choices": [{"delta": {"content": "33"}}]}]
|
|
)
|
|
payload = ResponsesRequest(input = "hi", stream = True, model = "org/M-GGUF")
|
|
messages = [ChatMessage(role = "user", content = "hi")]
|
|
|
|
llama_admission.reset_llama_admission_queues()
|
|
try:
|
|
|
|
async def run():
|
|
# Hold the backend's only decode slot so the run below has to queue.
|
|
queue = llama_admission.get_llama_admission_queue("http://llama.test")
|
|
holder = queue.reserve(capacity = 1, config = llama_admission.LlamaAdmissionConfig())
|
|
assert holder.lease_nowait() is not None
|
|
response = await inf_mod._responses_stream(
|
|
payload, messages, _NeverDisconnectedRequest()
|
|
)
|
|
chunks = []
|
|
|
|
async def drain():
|
|
async for chunk in response.body_iterator:
|
|
chunks.append(chunk)
|
|
|
|
task = asyncio.create_task(drain())
|
|
for _ in range(500):
|
|
if active_generations.count() == 1:
|
|
break
|
|
await asyncio.sleep(0.01)
|
|
assert active_generations.count() == 1, "the queued run never registered"
|
|
assert active_generations.cancel_all() == 1
|
|
# Unbounded queue by default: without the tracked event this never returns while the slot is held.
|
|
await asyncio.wait_for(task, timeout = 5)
|
|
return chunks
|
|
|
|
chunks = asyncio.run(run())
|
|
finally:
|
|
llama_admission.reset_llama_admission_queues()
|
|
|
|
body = "".join(c.decode() if isinstance(c, bytes) else c for c in chunks)
|
|
# It gave up its place instead of taking the slot: no upstream call, no envelope.
|
|
assert "response.created" not in body
|
|
assert active_generations.count() == 0
|
|
|
|
|
|
def _install_completions_stream_mock(monkeypatch, events):
|
|
"""Point the /v1/completions proxy at an in-process llama-server."""
|
|
import json
|
|
from types import SimpleNamespace
|
|
|
|
import httpx
|
|
|
|
import routes.inference as inf_mod
|
|
|
|
def handler(request):
|
|
# One network chunk per SSE event: the relay polls its cancel flag between upstream chunks.
|
|
async def _chunks():
|
|
for event in events:
|
|
yield f"data: {json.dumps(event)}\n\n".encode()
|
|
yield b"data: [DONE]\n\n"
|
|
|
|
return httpx.Response(
|
|
200,
|
|
content = _chunks(),
|
|
headers = {"content-type": "text/event-stream"},
|
|
)
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
real_async_client = httpx.AsyncClient
|
|
monkeypatch.setattr(
|
|
inf_mod.httpx,
|
|
"AsyncClient",
|
|
lambda *a, **kw: real_async_client(transport = transport, timeout = kw.get("timeout", 600)),
|
|
)
|
|
monkeypatch.setattr(
|
|
inf_mod,
|
|
"get_llama_cpp_backend",
|
|
lambda: SimpleNamespace(
|
|
is_loaded = True,
|
|
context_length = 4096,
|
|
base_url = "http://llama.test",
|
|
model_identifier = "org/M-GGUF",
|
|
),
|
|
)
|
|
monkeypatch.setattr(inf_mod, "_automatic_model_load_may_run", lambda: False)
|
|
|
|
async def _no_auto_switch(request, current_subject):
|
|
return await request.json()
|
|
|
|
monkeypatch.setattr(inf_mod, "_auto_switch_from_request_body", _no_auto_switch)
|
|
return inf_mod
|
|
|
|
|
|
class _CompletionsRequest(_NeverDisconnectedRequest):
|
|
"""Minimal stand-in for the Starlette Request /v1/completions reads."""
|
|
|
|
def __init__(self, body):
|
|
from types import SimpleNamespace
|
|
|
|
self._body = body
|
|
self.method = "POST"
|
|
self.url = SimpleNamespace(path = "/v1/completions")
|
|
|
|
async def json(self):
|
|
return self._body
|
|
|
|
|
|
def test_completions_proxy_stream_is_visible_to_the_swap_gate(monkeypatch):
|
|
# /v1/completions relays from llama-server with no idle drain; unregistered, /unload tore it down.
|
|
_route_gate()
|
|
import asyncio
|
|
|
|
inf_mod = _install_completions_stream_mock(monkeypatch, [{"choices": [{"text": "33"}]}])
|
|
request = _CompletionsRequest(
|
|
{"prompt": "hi", "stream": True, "model": "org/M-GGUF", "max_tokens": 8}
|
|
)
|
|
seen = {}
|
|
|
|
async def run():
|
|
response = await inf_mod.openai_completions(request, "tester")
|
|
iterator = response.body_iterator
|
|
await iterator.__anext__()
|
|
seen["count"] = active_generations.count()
|
|
seen["snapshot"] = active_generations.snapshot()
|
|
async for _ in iterator:
|
|
pass
|
|
|
|
asyncio.run(run())
|
|
|
|
assert seen["count"] == 1
|
|
assert seen["snapshot"][0]["model"] == "org/M-GGUF"
|
|
# And it unregisters, or one completion would 409 every later reload.
|
|
assert active_generations.count() == 0
|
|
|
|
|
|
def test_forced_reload_stops_a_completions_proxy_stream(monkeypatch):
|
|
# The registered event must be the one the relay watches, or a forced reload kills a live decode.
|
|
_route_gate()
|
|
import asyncio
|
|
|
|
inf_mod = _install_completions_stream_mock(
|
|
monkeypatch,
|
|
[{"choices": [{"text": "3"}]}, {"choices": [{"text": "3"}]}],
|
|
)
|
|
request = _CompletionsRequest(
|
|
{"prompt": "hi", "stream": True, "model": "org/M-GGUF", "max_tokens": 8}
|
|
)
|
|
|
|
async def run():
|
|
response = await inf_mod.openai_completions(request, "tester")
|
|
iterator = response.body_iterator
|
|
chunks = [await iterator.__anext__()]
|
|
assert active_generations.cancel_all() == 1
|
|
async for chunk in iterator:
|
|
chunks.append(chunk)
|
|
return b"".join(c if isinstance(c, bytes) else c.encode() for c in chunks)
|
|
|
|
body = asyncio.run(run())
|
|
|
|
# Stopped after the first event instead of relaying the rest.
|
|
assert body.count(b'"text"') == 1
|
|
assert active_generations.count() == 0
|
|
|
|
|
|
def test_completions_proxy_non_stream_is_visible_to_the_swap_gate(monkeypatch):
|
|
# ``stream`` defaults to false, so the non-streaming branch is the common shape and holds
|
|
# llama-server throughout: unregistered, /unload counts zero and force_cancel_active has no event.
|
|
_route_gate()
|
|
import asyncio
|
|
from types import SimpleNamespace
|
|
|
|
import httpx
|
|
|
|
import routes.inference as inf_mod
|
|
|
|
seen = {}
|
|
|
|
def handler(request):
|
|
# Sampled mid-flight: exactly the window a concurrent /unload would tear down in.
|
|
seen["count"] = active_generations.count()
|
|
seen["snapshot"] = active_generations.snapshot()
|
|
# And the gate must reach this run, not just see it.
|
|
seen["cancelled"] = active_generations.cancel_all()
|
|
return httpx.Response(200, json = {"id": "cmpl-x", "choices": [{"text": "33"}]})
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
real_async_client = httpx.AsyncClient
|
|
monkeypatch.setattr(
|
|
inf_mod.httpx,
|
|
"AsyncClient",
|
|
lambda *a, **kw: real_async_client(transport = transport, timeout = kw.get("timeout", 600)),
|
|
)
|
|
# The pooled client too, so a route that took no per-request one still reaches this transport.
|
|
monkeypatch.setattr(
|
|
inf_mod, "nonstreaming_client", lambda: real_async_client(transport = transport)
|
|
)
|
|
monkeypatch.setattr(
|
|
inf_mod,
|
|
"get_llama_cpp_backend",
|
|
lambda: SimpleNamespace(
|
|
is_loaded = True,
|
|
context_length = 4096,
|
|
base_url = "http://llama.test",
|
|
model_identifier = "org/M-GGUF",
|
|
),
|
|
)
|
|
monkeypatch.setattr(inf_mod, "_automatic_model_load_may_run", lambda: False)
|
|
|
|
async def _no_auto_switch(request, current_subject):
|
|
return await request.json()
|
|
|
|
monkeypatch.setattr(inf_mod, "_auto_switch_from_request_body", _no_auto_switch)
|
|
|
|
request = _CompletionsRequest({"prompt": "hi", "model": "org/M-GGUF", "max_tokens": 8})
|
|
|
|
with pytest.raises(asyncio.CancelledError):
|
|
asyncio.run(inf_mod.openai_completions(request, "tester"))
|
|
|
|
assert seen["count"] == 1
|
|
assert seen["snapshot"][0]["model"] == "org/M-GGUF"
|
|
assert seen["cancelled"] == 1
|
|
# And it unregisters, or one completion would 409 every later reload.
|
|
assert active_generations.count() == 0
|
|
|
|
|
|
class _EmbeddingsRequest(_NeverDisconnectedRequest):
|
|
"""Minimal stand-in for the Starlette Request /v1/embeddings reads."""
|
|
|
|
def __init__(self, body):
|
|
from types import SimpleNamespace
|
|
|
|
self._body = body
|
|
self.method = "POST"
|
|
self.url = SimpleNamespace(path = "/v1/embeddings")
|
|
self.state = SimpleNamespace(skip_api_monitor = True)
|
|
|
|
async def json(self):
|
|
return self._body
|
|
|
|
|
|
def test_embeddings_proxy_is_visible_to_the_swap_gate(monkeypatch):
|
|
# /v1/embeddings holds llama-server for its whole HTTP call: unregistered, a non-forced /unload
|
|
# counts zero and kills the server mid-request (only /load waits on the middleware count).
|
|
_route_gate()
|
|
import asyncio
|
|
from types import SimpleNamespace
|
|
|
|
import httpx
|
|
|
|
import routes.inference as inf_mod
|
|
|
|
seen = {}
|
|
|
|
def handler(request):
|
|
seen["count"] = active_generations.count()
|
|
seen["snapshot"] = active_generations.snapshot()
|
|
seen["cancelled"] = active_generations.cancel_all()
|
|
return httpx.Response(200, json = {"data": [{"embedding": [0.1, 0.2]}]})
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
real_async_client = httpx.AsyncClient
|
|
monkeypatch.setattr(
|
|
inf_mod.httpx,
|
|
"AsyncClient",
|
|
lambda *a, **kw: real_async_client(transport = transport, timeout = kw.get("timeout", 600)),
|
|
)
|
|
monkeypatch.setattr(
|
|
inf_mod, "nonstreaming_client", lambda: real_async_client(transport = transport)
|
|
)
|
|
monkeypatch.setattr(
|
|
inf_mod,
|
|
"get_llama_cpp_backend",
|
|
lambda: SimpleNamespace(
|
|
is_loaded = True,
|
|
context_length = 4096,
|
|
base_url = "http://llama.test",
|
|
model_identifier = "org/M-GGUF",
|
|
),
|
|
)
|
|
monkeypatch.setattr(inf_mod, "_automatic_model_load_may_run", lambda: False)
|
|
|
|
async def _no_auto_switch(request, current_subject):
|
|
return await request.json()
|
|
|
|
monkeypatch.setattr(inf_mod, "_auto_switch_from_request_body", _no_auto_switch)
|
|
|
|
request = _EmbeddingsRequest({"input": "hi", "model": "org/M-GGUF"})
|
|
|
|
with pytest.raises(asyncio.CancelledError):
|
|
asyncio.run(inf_mod.openai_embeddings(request, "tester"))
|
|
|
|
assert seen["count"] == 1
|
|
assert seen["snapshot"][0]["model"] == "org/M-GGUF"
|
|
assert seen["cancelled"] == 1
|
|
# And it unregisters, or one embedding would 409 every later reload.
|
|
assert active_generations.count() == 0
|
|
|
|
|
|
def test_active_generations_redacts_native_model_paths(monkeypatch):
|
|
# The legacy stream records active_model_name verbatim (an absolute path locally) and is the only
|
|
# place that serialises it: redact like the error paths so a remote client cannot learn host paths.
|
|
_route_gate()
|
|
import asyncio
|
|
import threading
|
|
from types import SimpleNamespace
|
|
|
|
import routes.inference as inf_mod
|
|
from utils.native_path_leases import _remember_native_path_for_redaction
|
|
|
|
secret_path = "/home/somebody/models/private-model.gguf"
|
|
_remember_native_path_for_redaction(secret_path, "private-model.gguf")
|
|
|
|
request = SimpleNamespace(app = SimpleNamespace(state = SimpleNamespace(llama_parallel_slots = 4)))
|
|
monkeypatch.setattr(inf_mod, "get_llama_cpp_backend", lambda: SimpleNamespace())
|
|
|
|
with active_generations.ActiveGeneration(threading.Event(), thread_id = "t1", model = secret_path):
|
|
body = asyncio.run(inf_mod.get_active_generations(request, "tester"))
|
|
|
|
assert body["count"] == 1
|
|
assert secret_path not in str(body)
|
|
assert body["active"][0]["model"] == "<native_path>"
|
|
|
|
|
|
def test_legacy_generate_stream_is_visible_to_the_swap_gate(monkeypatch):
|
|
# The legacy /generate/stream decodes on the standard backend throughout: unregistered it passed
|
|
# the advertised 409 gate then blocked on the generation lock, and a forced swap had no event.
|
|
_route_gate()
|
|
import asyncio
|
|
from types import SimpleNamespace
|
|
|
|
import routes.inference as inf_mod
|
|
from models.inference import GenerateRequest
|
|
|
|
seen = {}
|
|
|
|
def _fake_generate_chat_response(**kwargs):
|
|
# Sampled mid-generation: exactly the window an /unload would land in.
|
|
seen["count"] = active_generations.count()
|
|
seen["snapshot"] = active_generations.snapshot()
|
|
seen["cancelled"] = active_generations.cancel_all()
|
|
yield "hello"
|
|
yield "world"
|
|
|
|
backend = SimpleNamespace(
|
|
active_model_name = "org/M",
|
|
models = {"org/M": {}},
|
|
generate_chat_response = lambda **kw: _fake_generate_chat_response(**kw),
|
|
reset_generation_state = lambda *a: None,
|
|
resize_image = lambda img: img,
|
|
)
|
|
monkeypatch.setattr(inf_mod, "get_inference_backend", lambda: backend)
|
|
|
|
async def _drain():
|
|
response = await inf_mod.generate_stream(
|
|
GenerateRequest(messages = [{"role": "user", "content": "hi"}]),
|
|
_NeverDisconnectedRequest(),
|
|
current_subject = "tester",
|
|
)
|
|
async for _ in response.body_iterator:
|
|
pass
|
|
|
|
asyncio.run(_drain())
|
|
|
|
assert seen["count"] == 1
|
|
assert seen["snapshot"][0]["model"] == "org/M"
|
|
assert seen["cancelled"] == 1
|
|
# And it unregisters, or one legacy stream would 409 every later reload.
|
|
assert active_generations.count() == 0
|
|
|
|
|
|
def _anthropic_stream_args(chunks):
|
|
"""(request, cancel_event, run_gen) for the local Anthropic stream helpers."""
|
|
cancel_event = threading.Event()
|
|
|
|
def run_gen():
|
|
def _gen():
|
|
for chunk in chunks:
|
|
if cancel_event.is_set():
|
|
return
|
|
yield chunk
|
|
|
|
return _gen()
|
|
|
|
return _NeverDisconnectedRequest(), cancel_event, run_gen
|
|
|
|
|
|
def test_local_anthropic_plain_stream_is_visible_to_the_swap_gate(monkeypatch):
|
|
# Only the client-tool pass-through registered, so the no-tool /v1/messages path died mid-response.
|
|
_route_gate()
|
|
import asyncio
|
|
|
|
import routes.inference as inf_mod
|
|
|
|
request, cancel_event, run_gen = _anthropic_stream_args(["3", "33"])
|
|
seen = {}
|
|
|
|
async def run():
|
|
response = await inf_mod._anthropic_plain_stream(
|
|
request, cancel_event, run_gen, "msg_1", "org/M-GGUF"
|
|
)
|
|
iterator = response.body_iterator
|
|
await iterator.__anext__()
|
|
seen["count"] = active_generations.count()
|
|
seen["snapshot"] = active_generations.snapshot()
|
|
async for _ in iterator:
|
|
pass
|
|
|
|
asyncio.run(run())
|
|
|
|
assert seen["count"] == 1
|
|
assert seen["snapshot"][0]["model"] == "org/M-GGUF"
|
|
assert active_generations.count() == 0
|
|
|
|
|
|
def test_forced_reload_stops_a_local_anthropic_plain_stream(monkeypatch):
|
|
# The event registered has to be the one the decode loop watches.
|
|
_route_gate()
|
|
import asyncio
|
|
|
|
import routes.inference as inf_mod
|
|
|
|
request, cancel_event, run_gen = _anthropic_stream_args(["3", "33", "333"])
|
|
|
|
async def run():
|
|
response = await inf_mod._anthropic_plain_stream(
|
|
request, cancel_event, run_gen, "msg_1", "org/M-GGUF"
|
|
)
|
|
iterator = response.body_iterator
|
|
chunks = [await iterator.__anext__()]
|
|
assert active_generations.cancel_all() == 1
|
|
async for chunk in iterator:
|
|
chunks.append(chunk)
|
|
return "".join(c.decode() if isinstance(c, bytes) else c for c in chunks)
|
|
|
|
body = asyncio.run(run())
|
|
|
|
assert cancel_event.is_set()
|
|
# Cancelled mid-stream: no clean message_stop envelope.
|
|
assert "message_stop" not in body
|
|
assert active_generations.count() == 0
|
|
|
|
|
|
def test_local_anthropic_tool_stream_is_visible_to_the_swap_gate(monkeypatch):
|
|
# Same gap on the server-tool path (enable_tools / Anthropic server tools).
|
|
_route_gate()
|
|
import asyncio
|
|
|
|
import routes.inference as inf_mod
|
|
|
|
request, cancel_event, run_gen = _anthropic_stream_args(
|
|
[{"type": "content", "text": "3"}, {"type": "content", "text": "33"}]
|
|
)
|
|
seen = {}
|
|
|
|
async def run():
|
|
response = await inf_mod._anthropic_tool_stream(
|
|
request, cancel_event, run_gen, "msg_1", "org/M-GGUF"
|
|
)
|
|
iterator = response.body_iterator
|
|
await iterator.__anext__()
|
|
seen["count"] = active_generations.count()
|
|
seen["snapshot"] = active_generations.snapshot()
|
|
async for _ in iterator:
|
|
pass
|
|
|
|
asyncio.run(run())
|
|
|
|
assert seen["count"] == 1
|
|
assert seen["snapshot"][0]["model"] == "org/M-GGUF"
|
|
assert active_generations.count() == 0
|
|
|
|
|
|
def test_load_and_unload_requests_default_to_not_cancelling():
|
|
pytest.importorskip("pydantic", reason = "pydantic not installed")
|
|
from models.inference import LoadRequest, UnloadRequest
|
|
|
|
assert LoadRequest(model_path = "m").force_cancel_active is False
|
|
assert UnloadRequest(model_path = "m").force_cancel_active is False
|
|
assert LoadRequest(model_path = "m", force_cancel_active = True).force_cancel_active is True
|
|
|
|
|
|
def _parallel_constants(path: str) -> dict:
|
|
"""Read the _PARALLEL_* constants from a file's source.
|
|
|
|
Importing run.py would drag in the whole server to read three integers.
|
|
"""
|
|
import ast
|
|
|
|
with open(path, encoding = "utf-8") as f:
|
|
tree = ast.parse(f.read())
|
|
found = {}
|
|
for node in tree.body:
|
|
if not isinstance(node, ast.Assign):
|
|
continue
|
|
for target in node.targets:
|
|
name = getattr(target, "id", "")
|
|
if name.startswith("_PARALLEL_") and isinstance(node.value, ast.Constant):
|
|
found[name] = node.value.value
|
|
return found
|
|
|
|
|
|
def test_studio_defaults_to_more_than_one_decode_slot():
|
|
# With one slot the admission queue serialises every chat.
|
|
consts = _parallel_constants(os.path.join(_backend, "run.py"))
|
|
|
|
assert consts["_PARALLEL_DEFAULT_PLAIN"] > 1
|
|
assert consts["_PARALLEL_MIN"] <= consts["_PARALLEL_DEFAULT_PLAIN"] <= consts["_PARALLEL_MAX"]
|
|
|
|
|
|
def test_cli_and_backend_parallel_defaults_agree():
|
|
# argparse and the typer CLI are separate entry points into the same server.
|
|
backend = _parallel_constants(os.path.join(_backend, "run.py"))
|
|
cli_path = os.path.join(
|
|
os.path.dirname(os.path.dirname(os.path.abspath(_backend))),
|
|
"unsloth_cli",
|
|
"commands",
|
|
"studio.py",
|
|
)
|
|
cli = _parallel_constants(cli_path)
|
|
|
|
assert cli["_PARALLEL_DEFAULT_PLAIN"] == backend["_PARALLEL_DEFAULT_PLAIN"]
|
|
|
|
|
|
def _run_server_parallel_default(path: str, consts: dict):
|
|
"""Resolve run_server()'s llama_parallel_slots default from run.py's source."""
|
|
import ast
|
|
|
|
with open(path, encoding = "utf-8") as f:
|
|
tree = ast.parse(f.read())
|
|
for node in tree.body:
|
|
if not isinstance(node, ast.FunctionDef) or node.name != "run_server":
|
|
continue
|
|
args = node.args.args
|
|
defaults = node.args.defaults
|
|
# defaults align with the tail of the positional arg list.
|
|
for arg, default in zip(args[len(args) - len(defaults) :], defaults):
|
|
if arg.arg != "llama_parallel_slots":
|
|
continue
|
|
if isinstance(default, ast.Constant):
|
|
return default.value
|
|
if isinstance(default, ast.Name):
|
|
return consts.get(default.id)
|
|
return None
|
|
return None
|
|
|
|
|
|
def test_run_server_default_matches_the_cli_parallel_default():
|
|
# colab.py omits llama_parallel_slots, so the signature default is what Colab runs with.
|
|
run_path = os.path.join(_backend, "run.py")
|
|
consts = _parallel_constants(run_path)
|
|
|
|
default = _run_server_parallel_default(run_path, consts)
|
|
|
|
assert default is not None, "run_server() must keep a llama_parallel_slots default"
|
|
assert default == consts["_PARALLEL_DEFAULT_PLAIN"]
|
|
assert default > 1
|
|
|
|
|
|
def test_colab_launcher_inherits_the_parallel_default():
|
|
# Guard the inheritance itself: an explicit 1 here would resurrect the bug.
|
|
import ast
|
|
|
|
colab_path = os.path.join(_backend, "colab.py")
|
|
with open(colab_path, encoding = "utf-8") as f:
|
|
tree = ast.parse(f.read())
|
|
consts = _parallel_constants(os.path.join(_backend, "run.py"))
|
|
|
|
calls = [
|
|
node
|
|
for node in ast.walk(tree)
|
|
if isinstance(node, ast.Call) and getattr(node.func, "id", "") == "run_server"
|
|
]
|
|
assert calls, "colab.py must still launch the backend through run_server()"
|
|
for call in calls:
|
|
for kw in call.keywords:
|
|
if kw.arg != "llama_parallel_slots":
|
|
continue
|
|
value = kw.value.value if isinstance(kw.value, ast.Constant) else None
|
|
assert (
|
|
value is None or value > 1
|
|
), "colab.py pins llama_parallel_slots to 1; Colab chats would serialise"
|
|
# Whether pinned or inherited, Colab must end up with more than one slot.
|
|
assert consts["_PARALLEL_DEFAULT_PLAIN"] > 1
|
|
|
|
|
|
# ── the point of no return ────────────────────────────────────────────
|
|
|
|
|
|
def test_a_forced_load_that_loses_to_a_sidecar_install_leaves_the_chats_alone(monkeypatch):
|
|
# The destructive cancel is the point of no return: nothing after it may reject the load. A sidecar
|
|
# install can reserve the window during preflight, so its recheck must run before, not after.
|
|
_route_gate()
|
|
import asyncio
|
|
import contextlib
|
|
from types import SimpleNamespace
|
|
|
|
from fastapi import HTTPException
|
|
|
|
from models.inference import LoadRequest
|
|
|
|
inf_mod = _stub_load_route(monkeypatch, active_model_name = "org/OTHER")
|
|
monkeypatch.setattr(inf_mod, "_hf_offline_if_dns_dead", contextlib.nullcontext)
|
|
monkeypatch.setattr(
|
|
inf_mod.ModelConfig,
|
|
"from_identifier",
|
|
staticmethod(
|
|
lambda **kwargs: SimpleNamespace(
|
|
is_gguf = False,
|
|
identifier = "org/A",
|
|
display_name = "A",
|
|
is_vision = False,
|
|
is_lora = False,
|
|
path = None,
|
|
)
|
|
),
|
|
)
|
|
monkeypatch.setattr(inf_mod, "_mlx_distributed_launch_detected", lambda: False)
|
|
monkeypatch.setattr(inf_mod, "_guard_chat_load_against_training", lambda *a, **k: None)
|
|
monkeypatch.setattr(inf_mod, "_resolve_inherited_extra_args", lambda *a, **k: None)
|
|
|
|
# The two route-level checks pass, every check after them 409s.
|
|
seen = {"calls": 0}
|
|
|
|
def _sidecar_reserved_during_preflight():
|
|
seen["calls"] += 1
|
|
if seen["calls"] > 2:
|
|
raise HTTPException(
|
|
status_code = 409,
|
|
detail = "A transformers installation is in progress. Retry when it completes.",
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
inf_mod, "_raise_if_sidecar_swap_in_progress", _sidecar_reserved_during_preflight
|
|
)
|
|
|
|
fastapi_request = SimpleNamespace(
|
|
app = SimpleNamespace(state = SimpleNamespace(llama_parallel_slots = 1))
|
|
)
|
|
|
|
ev = threading.Event()
|
|
with active_generations.ActiveGeneration(ev, thread_id = "t1"):
|
|
with pytest.raises(HTTPException) as exc:
|
|
asyncio.run(
|
|
inf_mod.load_model(
|
|
LoadRequest(
|
|
model_path = "org/A",
|
|
load_in_4bit = False,
|
|
force_cancel_active = True,
|
|
),
|
|
fastapi_request,
|
|
"tester",
|
|
)
|
|
)
|
|
# The load was rejected, so the chat must still be streaming.
|
|
assert not ev.is_set()
|
|
assert active_generations.count() == 1
|
|
assert exc.value.status_code == 409
|
|
|
|
|
|
def test_anthropic_passthrough_registers_nothing_until_its_body_starts():
|
|
# A pass-through response whose body never starts must leave both registries clean: a never-started
|
|
# async generator runs no body code (PEP 342), so an eagerly entered tracker never unregisters.
|
|
_route_gate()
|
|
import asyncio
|
|
import inspect
|
|
from types import SimpleNamespace
|
|
|
|
from starlette.requests import ClientDisconnect
|
|
|
|
import routes.inference as inf_mod
|
|
|
|
llama_backend = SimpleNamespace(
|
|
base_url = "http://127.0.0.1:8080",
|
|
context_length = 4096,
|
|
count_chat_tokens = lambda messages, _unused, tools: 7,
|
|
)
|
|
|
|
async def _build():
|
|
return await inf_mod._anthropic_passthrough_stream(
|
|
SimpleNamespace(),
|
|
threading.Event(),
|
|
llama_backend,
|
|
[{"role": "user", "content": "hi"}],
|
|
[],
|
|
0.7,
|
|
0.9,
|
|
40,
|
|
128,
|
|
"msg_1",
|
|
"org/A",
|
|
session_id = "s1",
|
|
cancel_id = "c1",
|
|
)
|
|
|
|
# Built and abandoned, as when the request task is cancelled before Starlette calls the response.
|
|
asyncio.run(_build())
|
|
assert active_generations.count() == 0
|
|
assert not inf_mod._CANCEL_REGISTRY
|
|
|
|
# The client is gone at header time, so the first send fails and the body generator never runs.
|
|
async def _drive():
|
|
response = await _build()
|
|
|
|
async def _receive():
|
|
return {"type": "http.disconnect"}
|
|
|
|
async def _send(message):
|
|
raise OSError("client disconnected")
|
|
|
|
with pytest.raises(ClientDisconnect):
|
|
await response({"type": "http"}, _receive, _send)
|
|
|
|
asyncio.run(_drive())
|
|
assert active_generations.count() == 0
|
|
assert not inf_mod._CANCEL_REGISTRY
|
|
|
|
# Still tracked once the body runs: the enter stays inside the generator, under the finally.
|
|
src = inspect.getsource(inf_mod._anthropic_passthrough_stream)
|
|
assert src.index("async def _stream()") < src.index("_tracker.__enter__()")
|
|
assert src.index("_tracker.__enter__()") < src.index("_tracker.__exit__(None, None, None)")
|
|
|
|
|
|
def test_audio_generation_is_visible_to_the_swap_gate(monkeypatch):
|
|
# /audio/generate is non-streaming and holds the model for the whole request: unregistered, a
|
|
# non-forced swap counted zero and could tear it down mid-TTS, and a forced one had no entry.
|
|
_route_gate()
|
|
import asyncio
|
|
from types import SimpleNamespace
|
|
|
|
import routes.inference as inf_mod
|
|
from models.inference import ChatCompletionRequest
|
|
|
|
seen = {}
|
|
|
|
class _TtsBackend:
|
|
active_model_name = "org/TTS"
|
|
models = {"org/TTS": {"is_audio": True}}
|
|
|
|
def generate_audio_response(self, **kwargs):
|
|
# Sampled mid-generation: the window a concurrent swap would tear down in.
|
|
seen["count"] = active_generations.count()
|
|
seen["snapshot"] = active_generations.snapshot()
|
|
return (b"RIFFfake", 24000)
|
|
|
|
# is_loaded False picks the transformers TTS branch, not the GGUF one.
|
|
monkeypatch.setattr(
|
|
inf_mod,
|
|
"get_llama_cpp_backend",
|
|
lambda: SimpleNamespace(is_loaded = False, _is_audio = False),
|
|
)
|
|
monkeypatch.setattr(inf_mod, "get_inference_backend", lambda: _TtsBackend())
|
|
|
|
async def _no_auto_switch(*a, **k):
|
|
return None
|
|
|
|
monkeypatch.setattr(inf_mod, "_maybe_auto_switch_model", _no_auto_switch)
|
|
|
|
payload = ChatCompletionRequest(
|
|
model = "org/TTS",
|
|
messages = [{"role": "user", "content": "hi"}],
|
|
thread_id = "thread-tts",
|
|
)
|
|
asyncio.run(inf_mod.generate_audio(payload, request = None, current_subject = "tester"))
|
|
|
|
assert seen["count"] == 1
|
|
# Named, so the swap dialog can say which chat it would interrupt.
|
|
assert seen["snapshot"][0]["thread_id"] == "thread-tts"
|
|
# And it unregisters, or one TTS call would 409 every later reload.
|
|
assert active_generations.count() == 0
|
|
|
|
|
|
class _ChatRequest(_NeverDisconnectedRequest):
|
|
"""Minimal stand-in for the Starlette Request /v1/chat/completions reads."""
|
|
|
|
def __init__(self):
|
|
from types import SimpleNamespace
|
|
|
|
self.method = "POST"
|
|
self.url = SimpleNamespace(path = "/v1/chat/completions")
|
|
self.state = SimpleNamespace(skip_api_monitor = True)
|
|
self.scope: dict = {}
|
|
|
|
|
|
def _standard_chat_stubs(monkeypatch, backend):
|
|
"""Point /v1/chat/completions at a standard (non-GGUF) backend.
|
|
|
|
``supports_tools`` False keeps the request off the safetensors server-tool
|
|
loop, which registers on its own, so the plain default branch is exercised.
|
|
"""
|
|
from types import SimpleNamespace
|
|
|
|
import routes.inference as inf_mod
|
|
|
|
monkeypatch.setattr(
|
|
inf_mod,
|
|
"get_llama_cpp_backend",
|
|
lambda: SimpleNamespace(
|
|
is_loaded = False,
|
|
supports_tools = False,
|
|
is_vision = False,
|
|
context_length = None,
|
|
),
|
|
)
|
|
monkeypatch.setattr(inf_mod, "get_inference_backend", lambda: backend)
|
|
monkeypatch.setattr(inf_mod, "_automatic_model_load_may_run", lambda: False)
|
|
monkeypatch.setattr(
|
|
inf_mod, "_detect_safetensors_features", lambda *a, **k: {"supports_tools": False}
|
|
)
|
|
|
|
async def _no_auto_switch(*a, **k):
|
|
return None
|
|
|
|
monkeypatch.setattr(inf_mod, "_maybe_auto_switch_model", _no_auto_switch)
|
|
return inf_mod
|
|
|
|
|
|
def test_standard_non_stream_chat_is_visible_to_the_swap_gate(monkeypatch):
|
|
# ``stream`` defaults to false, so this is the default shape of a standard chat and it holds the
|
|
# worker throughout. Only the streaming branch registered, so a swap truncated the completion.
|
|
_route_gate()
|
|
import asyncio
|
|
|
|
import routes.inference as inf_mod
|
|
from models.inference import ChatCompletionRequest
|
|
|
|
seen = {}
|
|
|
|
class _StandardBackend:
|
|
active_model_name = "org/M"
|
|
models = {"org/M": {"chat_template_info": {"template": "chatml"}}}
|
|
|
|
def generate_chat_response(
|
|
self,
|
|
*,
|
|
cancel_event = None,
|
|
stats_holder = None,
|
|
**kwargs,
|
|
):
|
|
# Sampled mid-generation: exactly the window an /unload lands in.
|
|
seen["count"] = active_generations.count()
|
|
seen["snapshot"] = active_generations.snapshot()
|
|
# And the gate must reach this run, on the event the decode watches.
|
|
seen["cancelled"] = active_generations.cancel_all()
|
|
seen["reached_the_decode"] = cancel_event is not None and cancel_event.is_set()
|
|
yield "33"
|
|
|
|
def reset_generation_state(self, caller_cancel_event = None):
|
|
pass
|
|
|
|
_standard_chat_stubs(monkeypatch, _StandardBackend())
|
|
|
|
payload = ChatCompletionRequest(
|
|
model = "org/M",
|
|
messages = [{"role": "user", "content": "hi"}],
|
|
thread_id = "thread-chat",
|
|
)
|
|
response = asyncio.run(
|
|
inf_mod.openai_chat_completions(payload, _ChatRequest(), current_subject = "tester")
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert seen["count"] == 1
|
|
# Named, so the swap dialog can say which chat it would interrupt.
|
|
assert seen["snapshot"][0]["thread_id"] == "thread-chat"
|
|
assert seen["cancelled"] == 1
|
|
assert seen["reached_the_decode"]
|
|
# And it unregisters, or one completion would 409 every later reload.
|
|
assert active_generations.count() == 0
|
|
|
|
|
|
def test_standard_non_stream_chat_unregisters_when_it_fails(monkeypatch):
|
|
# A raising backend must not strand an entry: that would 409 every later swap.
|
|
_route_gate()
|
|
import asyncio
|
|
|
|
from fastapi import HTTPException
|
|
|
|
import routes.inference as inf_mod
|
|
from models.inference import ChatCompletionRequest
|
|
|
|
class _BrokenBackend:
|
|
active_model_name = "org/M"
|
|
models = {"org/M": {"chat_template_info": {"template": "chatml"}}}
|
|
|
|
def generate_chat_response(self, **kwargs):
|
|
raise RuntimeError("decode exploded")
|
|
yield # pragma: no cover - generator marker
|
|
|
|
def reset_generation_state(self, caller_cancel_event = None):
|
|
pass
|
|
|
|
_standard_chat_stubs(monkeypatch, _BrokenBackend())
|
|
|
|
payload = ChatCompletionRequest(model = "org/M", messages = [{"role": "user", "content": "hi"}])
|
|
with pytest.raises(HTTPException):
|
|
asyncio.run(
|
|
inf_mod.openai_chat_completions(payload, _ChatRequest(), current_subject = "tester")
|
|
)
|
|
|
|
assert active_generations.count() == 0
|
|
|
|
|
|
def test_audio_input_non_stream_chat_is_visible_to_the_swap_gate(monkeypatch):
|
|
# An audio-input model with the default stream=false holds the standard worker throughout. Only
|
|
# the streaming sibling registered, so a non-forced swap could unload it mid-transcription.
|
|
_route_gate()
|
|
import asyncio
|
|
|
|
import routes.inference as inf_mod
|
|
from models.inference import ChatCompletionRequest
|
|
|
|
seen = {}
|
|
|
|
class _AudioInputBackend:
|
|
active_model_name = "org/AUDIO-IN"
|
|
models = {"org/AUDIO-IN": {"has_audio_input": True}}
|
|
|
|
def generate_audio_input_response(
|
|
self,
|
|
*,
|
|
cancel_event = None,
|
|
**kwargs,
|
|
):
|
|
# Sampled mid-transcription: the window a concurrent swap lands in.
|
|
seen["count"] = active_generations.count()
|
|
seen["snapshot"] = active_generations.snapshot()
|
|
seen["cancelled"] = active_generations.cancel_all()
|
|
seen["reached_the_decode"] = cancel_event is not None and cancel_event.is_set()
|
|
yield "33"
|
|
|
|
def reset_generation_state(self, caller_cancel_event = None):
|
|
pass
|
|
|
|
_standard_chat_stubs(monkeypatch, _AudioInputBackend())
|
|
monkeypatch.setattr(inf_mod, "_decode_audio_base64", lambda _b64: object())
|
|
|
|
payload = ChatCompletionRequest(
|
|
model = "org/AUDIO-IN",
|
|
messages = [{"role": "user", "content": "transcribe this"}],
|
|
audio_base64 = "ZmFrZQ==",
|
|
thread_id = "thread-audio-in",
|
|
)
|
|
response = asyncio.run(
|
|
inf_mod.openai_chat_completions(payload, _ChatRequest(), current_subject = "tester")
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert seen["count"] == 1
|
|
assert seen["snapshot"][0]["thread_id"] == "thread-audio-in"
|
|
assert seen["cancelled"] == 1
|
|
assert seen["reached_the_decode"]
|
|
# And it unregisters, or one transcription would 409 every later reload.
|
|
assert active_generations.count() == 0
|
|
|
|
|
|
def _anthropic_route_stubs(monkeypatch, **overrides):
|
|
"""Minimal GGUF backend + request stub for the /v1/messages route."""
|
|
from types import SimpleNamespace
|
|
|
|
import routes.inference as inf_mod
|
|
from state.tool_policy import reset_tool_policy
|
|
|
|
reset_tool_policy()
|
|
backend = SimpleNamespace(
|
|
is_loaded = True,
|
|
is_vision = False,
|
|
supports_tools = True,
|
|
supports_tool_passthrough = True,
|
|
model_identifier = "org/M-GGUF",
|
|
base_url = "http://llama.test",
|
|
context_length = 4096,
|
|
count_chat_tokens = lambda *a, **k: 2,
|
|
)
|
|
backend.__dict__.update(overrides)
|
|
monkeypatch.setattr(inf_mod, "get_llama_cpp_backend", lambda: backend)
|
|
monkeypatch.setattr(inf_mod, "_automatic_model_load_may_run", lambda: False)
|
|
return inf_mod
|
|
|
|
|
|
class _MessagesRequest(_NeverDisconnectedRequest):
|
|
"""Minimal stand-in for the Starlette Request /v1/messages reads."""
|
|
|
|
def __init__(self):
|
|
from types import SimpleNamespace
|
|
|
|
self.method = "POST"
|
|
self.url = SimpleNamespace(path = "/v1/messages")
|
|
self.state = SimpleNamespace(skip_api_monitor = True)
|
|
|
|
|
|
@pytest.mark.parametrize("with_server_tools", [False, True])
|
|
def test_local_anthropic_non_stream_is_visible_to_the_swap_gate(monkeypatch, with_server_tools):
|
|
# ``stream`` defaults to false on /v1/messages, so the non-streaming plain and server-tool branches
|
|
# are the common shape and decode throughout. Only their streaming siblings registered.
|
|
_route_gate()
|
|
import asyncio
|
|
|
|
from models.inference import AnthropicMessagesRequest
|
|
|
|
seen = {}
|
|
|
|
def _sample():
|
|
# Sampled mid-generation: exactly the window an /unload lands in.
|
|
seen["count"] = active_generations.count()
|
|
seen["snapshot"] = active_generations.snapshot()
|
|
seen["cancelled"] = active_generations.cancel_all()
|
|
|
|
def _gen_plain(*, cancel_event = None, **kwargs):
|
|
_sample()
|
|
seen["reached_the_decode"] = cancel_event is not None and cancel_event.is_set()
|
|
yield "ok"
|
|
|
|
def _gen_tools(*, cancel_event = None, **kwargs):
|
|
_sample()
|
|
seen["reached_the_decode"] = cancel_event is not None and cancel_event.is_set()
|
|
yield {"type": "content", "text": "ok"}
|
|
|
|
inf_mod = _anthropic_route_stubs(
|
|
monkeypatch,
|
|
generate_chat_completion = _gen_plain,
|
|
generate_chat_completion_with_tools = _gen_tools,
|
|
)
|
|
|
|
fields = {"max_tokens": 16, "messages": [{"role": "user", "content": "hi"}]}
|
|
if with_server_tools:
|
|
fields["enable_tools"] = True
|
|
fields["tools"] = [{"type": "web_search_20250305", "name": "web_search"}]
|
|
payload = AnthropicMessagesRequest(**fields)
|
|
|
|
response = asyncio.run(
|
|
inf_mod.anthropic_messages(payload, request = _MessagesRequest(), current_subject = "tester")
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert seen["count"] == 1
|
|
assert seen["snapshot"][0]["model"] == "org/M-GGUF"
|
|
assert seen["cancelled"] == 1
|
|
# The event registered is the one the decode watches, so a forced swap lands.
|
|
assert seen["reached_the_decode"]
|
|
# And it unregisters, or one message would 409 every later reload.
|
|
assert active_generations.count() == 0
|
|
|
|
|
|
def test_anthropic_passthrough_non_stream_is_visible_to_the_swap_gate(monkeypatch):
|
|
# The client-tool pass-through holds llama-server for one non-streaming POST. Its streaming sibling
|
|
# registers inside the body generator; this branch had none, so /unload tore the server down.
|
|
_route_gate()
|
|
import asyncio
|
|
|
|
import httpx
|
|
|
|
from models.inference import AnthropicMessagesRequest
|
|
|
|
seen = {}
|
|
|
|
def handler(request):
|
|
seen["count"] = active_generations.count()
|
|
seen["snapshot"] = active_generations.snapshot()
|
|
seen["cancelled"] = active_generations.cancel_all()
|
|
return httpx.Response(
|
|
200,
|
|
json = {
|
|
"choices": [
|
|
{"message": {"role": "assistant", "content": "33"}, "finish_reason": "stop"}
|
|
]
|
|
},
|
|
)
|
|
|
|
inf_mod = _anthropic_route_stubs(monkeypatch)
|
|
transport = httpx.MockTransport(handler)
|
|
real_async_client = httpx.AsyncClient
|
|
# The pass-through takes a per-request client, so a Stop or forced swap can close it mid-POST.
|
|
monkeypatch.setattr(
|
|
inf_mod,
|
|
"_cancelable_nonstreaming_client",
|
|
lambda: real_async_client(transport = transport),
|
|
)
|
|
|
|
# enable_tools False keeps the server-tool loop out, so the client tool takes the pass-through.
|
|
payload = AnthropicMessagesRequest(
|
|
max_tokens = 16,
|
|
messages = [{"role": "user", "content": "hi"}],
|
|
enable_tools = False,
|
|
tools = [{"name": "lookup", "input_schema": {"type": "object", "properties": {}}}],
|
|
)
|
|
|
|
response = asyncio.run(
|
|
inf_mod.anthropic_messages(payload, request = _MessagesRequest(), current_subject = "tester")
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert seen["count"] == 1
|
|
assert seen["snapshot"][0]["model"] == "org/M-GGUF"
|
|
assert seen["cancelled"] == 1
|
|
# And it unregisters, or one message would 409 every later reload.
|
|
assert active_generations.count() == 0
|
|
|
|
|
|
def test_anthropic_passthrough_non_stream_stops_when_the_swap_cancels_it(monkeypatch):
|
|
# Registering is half the job: a pooled client cannot be closed, so the run was cancelled while the
|
|
# POST carried on. The watcher closes a per-request client; the set event makes that error a cancel.
|
|
_route_gate()
|
|
import asyncio
|
|
|
|
import httpx
|
|
|
|
from models.inference import AnthropicMessagesRequest
|
|
|
|
seen = {}
|
|
|
|
def handler(request):
|
|
# Stand in for a forced swap mid-decode: cancel, then fail the transport as closing would.
|
|
seen["cancelled"] = active_generations.cancel_all()
|
|
raise httpx.ConnectError("client closed")
|
|
|
|
inf_mod = _anthropic_route_stubs(monkeypatch)
|
|
transport = httpx.MockTransport(handler)
|
|
real_async_client = httpx.AsyncClient
|
|
monkeypatch.setattr(
|
|
inf_mod,
|
|
"_cancelable_nonstreaming_client",
|
|
lambda: real_async_client(transport = transport),
|
|
)
|
|
|
|
payload = AnthropicMessagesRequest(
|
|
max_tokens = 16,
|
|
messages = [{"role": "user", "content": "hi"}],
|
|
enable_tools = False,
|
|
tools = [{"name": "lookup", "input_schema": {"type": "object", "properties": {}}}],
|
|
)
|
|
|
|
with pytest.raises(asyncio.CancelledError):
|
|
asyncio.run(
|
|
inf_mod.anthropic_messages(
|
|
payload, request = _MessagesRequest(), current_subject = "tester"
|
|
)
|
|
)
|
|
|
|
assert seen["cancelled"] == 1
|
|
# Cancelled or not, the entry must go, or one message 409s every later reload.
|
|
assert active_generations.count() == 0
|
|
|
|
|
|
def test_audio_generation_unregisters_when_it_fails(monkeypatch):
|
|
# A raising backend must not strand an entry: that would 409 every later load.
|
|
_route_gate()
|
|
import asyncio
|
|
from types import SimpleNamespace
|
|
|
|
from fastapi import HTTPException
|
|
|
|
import routes.inference as inf_mod
|
|
from models.inference import ChatCompletionRequest
|
|
|
|
class _BrokenTtsBackend:
|
|
active_model_name = "org/TTS"
|
|
models = {"org/TTS": {"is_audio": True}}
|
|
|
|
def generate_audio_response(self, **kwargs):
|
|
raise RuntimeError("codec exploded")
|
|
|
|
monkeypatch.setattr(
|
|
inf_mod,
|
|
"get_llama_cpp_backend",
|
|
lambda: SimpleNamespace(is_loaded = False, _is_audio = False),
|
|
)
|
|
monkeypatch.setattr(inf_mod, "get_inference_backend", lambda: _BrokenTtsBackend())
|
|
|
|
async def _no_auto_switch(*a, **k):
|
|
return None
|
|
|
|
monkeypatch.setattr(inf_mod, "_maybe_auto_switch_model", _no_auto_switch)
|
|
|
|
payload = ChatCompletionRequest(
|
|
model = "org/TTS",
|
|
messages = [{"role": "user", "content": "hi"}],
|
|
)
|
|
with pytest.raises(HTTPException):
|
|
asyncio.run(inf_mod.generate_audio(payload, request = None, current_subject = "tester"))
|
|
|
|
assert active_generations.count() == 0
|
|
|
|
|
|
# ── sidecar install: carrying a confirmed swap through ─────────────────
|
|
|
|
|
|
def _stub_install_route(monkeypatch, *, in_flight_events):
|
|
"""Point POST /install-latest-transformers at an in-memory sidecar install.
|
|
|
|
``in_flight_events`` stands in for the middleware's in-flight count: a
|
|
request is counted until its stream observes the cancel event and unwinds,
|
|
which is the coupling the installer's guard actually reads.
|
|
"""
|
|
from types import SimpleNamespace
|
|
|
|
import core.inference.llama_keepwarm as keepwarm
|
|
import routes.inference as inf_mod
|
|
import utils.transformers_latest as latest_mod
|
|
import utils.transformers_version as version_mod
|
|
|
|
calls = {"installed": [], "released": 0}
|
|
|
|
monkeypatch.setattr(version_mod, "try_begin_sidecar_swap", lambda: True)
|
|
|
|
def _end_sidecar_swap():
|
|
calls["released"] += 1
|
|
|
|
monkeypatch.setattr(version_mod, "end_sidecar_swap", _end_sidecar_swap)
|
|
|
|
import core.export as export_mod
|
|
import core.training as training_mod
|
|
|
|
monkeypatch.setattr(
|
|
training_mod,
|
|
"get_training_backend",
|
|
lambda: SimpleNamespace(is_training_active = lambda: False),
|
|
)
|
|
monkeypatch.setattr(
|
|
export_mod,
|
|
"get_export_backend",
|
|
lambda: SimpleNamespace(is_export_active = lambda: False, current_checkpoint = None),
|
|
)
|
|
monkeypatch.setattr(
|
|
inf_mod,
|
|
"get_inference_backend",
|
|
lambda: SimpleNamespace(active_model_name = None, load_generation = 0),
|
|
)
|
|
|
|
def _fake_in_flight(current_request_counted = True, *, include_pending = True):
|
|
return sum(1 for ev in in_flight_events if not ev.is_set())
|
|
|
|
monkeypatch.setattr(keepwarm, "other_inference_request_count", _fake_in_flight)
|
|
|
|
def _install(version, before_swap, *args, **kwargs):
|
|
calls["installed"].append(version)
|
|
return {"success": True, "version": version, "message": "installed"}
|
|
|
|
monkeypatch.setattr(latest_mod, "install_latest_transformers", _install)
|
|
return inf_mod, calls
|
|
|
|
|
|
def test_confirmed_install_stops_the_chats_it_was_given_permission_to_stop(monkeypatch):
|
|
# The install sits between the swap's "stop N chats" prompt and the /load carrying the
|
|
# confirmation, and refuses while those chats run, so a confirmed install cancels them itself.
|
|
_route_gate()
|
|
import asyncio
|
|
|
|
from models.inference import InstallLatestTransformersRequest
|
|
|
|
ev = threading.Event()
|
|
inf_mod, calls = _stub_install_route(monkeypatch, in_flight_events = [ev])
|
|
|
|
with active_generations.ActiveGeneration(ev, thread_id = "t1", model = "org/M-GGUF"):
|
|
response = asyncio.run(
|
|
inf_mod.install_latest_transformers_route(
|
|
InstallLatestTransformersRequest(version = "5.0.0", force_cancel_active = True),
|
|
"tester",
|
|
)
|
|
)
|
|
assert ev.is_set()
|
|
|
|
assert response.success is True
|
|
assert calls["installed"] == ["5.0.0"]
|
|
|
|
|
|
def test_unconfirmed_install_still_refuses_while_chats_stream(monkeypatch):
|
|
# Unchanged for every caller that never confirmed (second tab, desktop, curl): no flag, no cancel.
|
|
_route_gate()
|
|
import asyncio
|
|
|
|
from fastapi import HTTPException
|
|
|
|
from models.inference import InstallLatestTransformersRequest
|
|
|
|
ev = threading.Event()
|
|
inf_mod, calls = _stub_install_route(monkeypatch, in_flight_events = [ev])
|
|
|
|
with active_generations.ActiveGeneration(ev, thread_id = "t1"):
|
|
with pytest.raises(HTTPException) as exc:
|
|
asyncio.run(
|
|
inf_mod.install_latest_transformers_route(
|
|
InstallLatestTransformersRequest(version = "5.0.0"),
|
|
"tester",
|
|
)
|
|
)
|
|
assert not ev.is_set()
|
|
assert active_generations.count() == 1
|
|
|
|
assert exc.value.status_code == 409
|
|
assert calls["installed"] == []
|
|
|
|
|
|
def test_a_confirmed_install_that_cannot_drain_refuses_instead_of_swapping(monkeypatch):
|
|
# A cancelled request that never observes its event keeps the in-flight count up, so the drain is
|
|
# bounded and cannot wedge the process holding the gate; the recheck behind it still refuses.
|
|
_route_gate()
|
|
import asyncio
|
|
|
|
from fastapi import HTTPException
|
|
|
|
from models.inference import InstallLatestTransformersRequest
|
|
|
|
ev = threading.Event()
|
|
stuck = threading.Event()
|
|
stuck.set() # already "cancelled", yet still counted: it never unwinds
|
|
inf_mod, calls = _stub_install_route(monkeypatch, in_flight_events = [ev, stuck])
|
|
monkeypatch.setattr(inf_mod, "_POST_CANCEL_DRAIN_TIMEOUT_S", 0.05)
|
|
|
|
def _never_unwinds(current_request_counted = True, *, include_pending = True):
|
|
return 1
|
|
|
|
import core.inference.llama_keepwarm as keepwarm
|
|
|
|
monkeypatch.setattr(keepwarm, "other_inference_request_count", _never_unwinds)
|
|
|
|
async def _install():
|
|
# Deadline here too: a regression that drops the drain's bound must fail, not hang the suite.
|
|
return await asyncio.wait_for(
|
|
inf_mod.install_latest_transformers_route(
|
|
InstallLatestTransformersRequest(version = "5.0.0", force_cancel_active = True),
|
|
"tester",
|
|
),
|
|
timeout = 5,
|
|
)
|
|
|
|
with active_generations.ActiveGeneration(ev, thread_id = "t1"):
|
|
with pytest.raises(HTTPException) as exc:
|
|
asyncio.run(_install())
|
|
|
|
assert exc.value.status_code == 409
|
|
assert calls["installed"] == []
|
|
|
|
|
|
def test_confirmed_install_does_not_spend_its_cancel_on_an_install_that_will_refuse(monkeypatch):
|
|
# An unrelated counted request the cancel cannot stop must be waited out BEFORE the cancel: the
|
|
# recheck refuses while it is there, so cancelling first stopped chats for a doomed install.
|
|
_route_gate()
|
|
import asyncio
|
|
|
|
from fastapi import HTTPException
|
|
|
|
from models.inference import InstallLatestTransformersRequest
|
|
|
|
ev = threading.Event()
|
|
inf_mod, calls = _stub_install_route(monkeypatch, in_flight_events = [ev])
|
|
|
|
import core.inference.llama_keepwarm as keepwarm
|
|
|
|
def _never_drains(current_request_counted = True, *, include_pending = True):
|
|
# Discounting the registered chat still leaves the counted-only stranger: the drain must not clear.
|
|
return 2
|
|
|
|
monkeypatch.setattr(keepwarm, "other_inference_request_count", _never_drains)
|
|
monkeypatch.setattr(inf_mod, "_POST_CANCEL_DRAIN_TIMEOUT_S", 0.05)
|
|
|
|
async def _install():
|
|
return await asyncio.wait_for(
|
|
inf_mod.install_latest_transformers_route(
|
|
InstallLatestTransformersRequest(version = "5.0.0", force_cancel_active = True),
|
|
"tester",
|
|
),
|
|
timeout = 5,
|
|
)
|
|
|
|
with active_generations.ActiveGeneration(ev, thread_id = "t1"):
|
|
with pytest.raises(HTTPException) as exc:
|
|
asyncio.run(_install())
|
|
# The refusal is the same as before; what changed is that the chat lives.
|
|
assert not ev.is_set()
|
|
assert active_generations.count() == 1
|
|
|
|
assert exc.value.status_code == 409
|
|
assert calls["installed"] == []
|
|
|
|
|
|
# ── draining before teardown ──────────────────────────────────────────
|
|
|
|
|
|
def _drain_with_counts(monkeypatch, counts, **kwargs):
|
|
"""Run _wait_for_model_switch_idle against a scripted in-flight count.
|
|
|
|
``counts`` is consumed one entry per poll; the last value repeats, so a
|
|
trailing non-zero stands for a request that never unwinds.
|
|
"""
|
|
_route_gate()
|
|
import asyncio
|
|
|
|
import core.inference.llama_keepwarm as keepwarm
|
|
import routes.inference as inf_mod
|
|
|
|
remaining = list(counts)
|
|
polls = {"n": 0}
|
|
|
|
def _count(current_request_counted = True, *, include_pending = True):
|
|
polls["n"] += 1
|
|
return remaining.pop(0) if len(remaining) > 1 else remaining[0]
|
|
|
|
monkeypatch.setattr(keepwarm, "other_inference_request_count", _count)
|
|
monkeypatch.setattr(inf_mod, "_switch_waiter_count", lambda: 0)
|
|
|
|
async def _run():
|
|
# Hard test-side deadline: a drain that regresses to waiting forever must fail red, not hang.
|
|
await asyncio.wait_for(
|
|
inf_mod._wait_for_model_switch_idle(current_request_counted = False, **kwargs),
|
|
timeout = 5,
|
|
)
|
|
|
|
asyncio.run(_run())
|
|
return polls["n"]
|
|
|
|
|
|
def test_forced_swap_does_not_wait_out_the_generations_it_is_about_to_cancel(monkeypatch):
|
|
# cancel_pending discounts the registered generations, since the caller cancels them right after.
|
|
# Drop the discount and the drain waits on a count only that pending cancel can lower: forever.
|
|
ev = threading.Event()
|
|
with active_generations.ActiveGeneration(ev, thread_id = "t1"):
|
|
polls = _drain_with_counts(monkeypatch, [1], cancel_pending = True)
|
|
assert polls == 1
|
|
|
|
|
|
def test_the_same_drain_without_the_discount_would_keep_waiting(monkeypatch):
|
|
# The other half: that count really does block, so the previous test passes by the discount.
|
|
ev = threading.Event()
|
|
with active_generations.ActiveGeneration(ev, thread_id = "t1"):
|
|
polls = _drain_with_counts(monkeypatch, [1], timeout_s = 0.05)
|
|
assert polls > 1
|
|
|
|
|
|
def test_post_cancel_drain_gives_up_on_a_request_that_never_unwinds(monkeypatch):
|
|
# TTS on the subprocess backend observes no cancel event, so a forced swap can cancel it and still
|
|
# see it counted forever. The post-cancel drains hold the gate, so they must expire and proceed.
|
|
polls = _drain_with_counts(monkeypatch, [1], timeout_s = 0.05)
|
|
assert polls > 1
|
|
|
|
|
|
def test_drain_returns_as_soon_as_the_cancelled_requests_unwind(monkeypatch):
|
|
# The bound is a backstop: once the count drops the drain returns without sitting out the timeout.
|
|
polls = _drain_with_counts(monkeypatch, [2, 1, 0], timeout_s = 30)
|
|
assert polls == 3
|
|
|
|
|
|
# ── queued chats must not cancel the running one ──────────────────────
|
|
|
|
|
|
def _orchestrator_for_ownership():
|
|
"""A real InferenceOrchestrator with just enough stubbed to drive the lock."""
|
|
_route_gate()
|
|
orch_mod = pytest.importorskip(
|
|
"core.inference.orchestrator", reason = "inference stack not installed"
|
|
)
|
|
orch = orch_mod.InferenceOrchestrator.__new__(orch_mod.InferenceOrchestrator)
|
|
orch._gen_lock = threading.Lock()
|
|
orch._active_cancel_events = []
|
|
orch._executing_cancel_events = []
|
|
orch._active_cancel_lock = threading.Lock()
|
|
orch._cancel_event = threading.Event()
|
|
orch._ensure_subprocess_alive = lambda: False # stop before _send_cmd
|
|
return orch
|
|
|
|
|
|
def test_a_queued_chat_cannot_reset_the_chat_that_is_generating():
|
|
# Safetensors generation serialises on _gen_lock and the worker has ONE cancel event: stopping
|
|
# queued chat B reset that shared event and killed running chat A. Scope the reset to the holder.
|
|
orch = _orchestrator_for_ownership()
|
|
a_event = threading.Event()
|
|
b_event = threading.Event()
|
|
|
|
orch._claim_worker(a_event) # A holds the lock ...
|
|
orch._mark_worker_started(a_event) # ... and the worker is answering it
|
|
orch.reset_generation_state(b_event) # B is queued and gets stopped
|
|
assert not orch._cancel_event.is_set()
|
|
|
|
orch.reset_generation_state(a_event) # A's own Stop still works
|
|
assert orch._cancel_event.is_set()
|
|
|
|
|
|
def test_a_global_reset_still_cancels_whatever_is_running():
|
|
# Unload and switch pass nothing: they mean stop everything, else a generation survives teardown.
|
|
orch = _orchestrator_for_ownership()
|
|
_running = threading.Event()
|
|
orch._claim_worker(_running)
|
|
orch._mark_worker_started(_running)
|
|
orch.reset_generation_state()
|
|
assert orch._cancel_event.is_set()
|
|
|
|
|
|
def test_a_reset_with_no_generation_running_is_not_dropped():
|
|
# Nothing holds the lock, so no chat to protect: a reset before any generation must still run.
|
|
orch = _orchestrator_for_ownership()
|
|
orch.reset_generation_state(threading.Event())
|
|
assert orch._cancel_event.is_set()
|
|
|
|
|
|
def test_unload_waits_for_a_request_that_is_admitted_but_not_yet_registered(monkeypatch):
|
|
# The window between the keep-warm middleware and _TrackedCancel: counted in-flight, absent from
|
|
# the registry. Cancelling on the registry alone tore the backend down under an admitted request.
|
|
_route_gate()
|
|
import core.inference.llama_keepwarm as keepwarm
|
|
import routes.inference as inf_mod
|
|
|
|
# Counted for two polls, then the request registers/finishes and clears.
|
|
remaining = [1, 1, 0]
|
|
seen = {}
|
|
|
|
def _count(current_request_counted = True, *, include_pending = True):
|
|
return remaining.pop(0) if len(remaining) > 1 else remaining[0]
|
|
|
|
monkeypatch.setattr(keepwarm, "other_inference_request_count", _count)
|
|
monkeypatch.setattr(inf_mod, "_switch_waiter_count", lambda: 0)
|
|
|
|
torn_down: list[str] = []
|
|
|
|
def _record_teardown():
|
|
seen["counted_at_teardown"] = remaining[0]
|
|
torn_down.append("gguf")
|
|
|
|
# Registry deliberately empty: this is the unregistered case.
|
|
response = _run_unload(
|
|
inf_mod,
|
|
monkeypatch,
|
|
loaded_gguf = "org/A-GGUF",
|
|
requested = "org/A-GGUF",
|
|
force = True,
|
|
torn_down = torn_down,
|
|
unload_model = _record_teardown,
|
|
)
|
|
|
|
assert active_generations.count() == 0
|
|
assert torn_down == ["gguf"]
|
|
assert seen["counted_at_teardown"] == 0
|
|
assert response.status == "unloaded"
|
|
|
|
|
|
def test_a_dispatched_chat_cannot_reset_its_concurrently_dispatched_sibling():
|
|
# Compare-mode / dispatched runs bypass _gen_lock and run concurrently, so with several claimed
|
|
# at once a Stop on one must still leave the others alone.
|
|
orch = _orchestrator_for_ownership()
|
|
a_event = threading.Event()
|
|
b_event = threading.Event()
|
|
c_event = threading.Event()
|
|
|
|
orch._claim_worker(a_event)
|
|
orch._mark_worker_started(a_event)
|
|
orch._claim_worker(b_event)
|
|
orch._mark_worker_started(b_event)
|
|
|
|
orch.reset_generation_state(c_event) # a third, unrelated request
|
|
assert not orch._cancel_event.is_set()
|
|
|
|
orch.reset_generation_state(b_event) # one of the running pair
|
|
assert orch._cancel_event.is_set()
|
|
|
|
|
|
def test_releasing_one_generation_leaves_the_other_claimed():
|
|
orch = _orchestrator_for_ownership()
|
|
a_event = threading.Event()
|
|
b_event = threading.Event()
|
|
orch._claim_worker(a_event)
|
|
orch._mark_worker_started(a_event)
|
|
orch._claim_worker(b_event)
|
|
orch._mark_worker_started(b_event)
|
|
orch._release_worker(a_event)
|
|
|
|
orch.reset_generation_state(a_event) # now a stranger
|
|
assert not orch._cancel_event.is_set()
|
|
|
|
orch._release_worker(b_event)
|
|
orch.reset_generation_state(a_event) # nothing running: no one to protect
|
|
assert orch._cancel_event.is_set()
|
|
|
|
|
|
def test_a_dispatched_request_queued_behind_another_is_not_an_owner():
|
|
# The subprocess runs generations one at a time, so admission is not execution: B can be claimed
|
|
# while the worker answers A. Counting B as an owner let its Stop signal the shared event and end A.
|
|
orch = _orchestrator_for_ownership()
|
|
a_event = threading.Event()
|
|
b_event = threading.Event()
|
|
|
|
orch._claim_worker(a_event)
|
|
orch._mark_worker_started(a_event) # the worker answered A
|
|
orch._claim_worker(b_event) # B is only queued behind it
|
|
|
|
orch.reset_generation_state(b_event)
|
|
assert not orch._cancel_event.is_set(), "a queued request must not reset A"
|
|
|
|
orch._mark_worker_started(b_event) # the worker moves on to B
|
|
orch.reset_generation_state(b_event)
|
|
assert orch._cancel_event.is_set()
|
|
|
|
|
|
def test_a_queued_request_cannot_reset_during_the_other_ones_prefill():
|
|
# Between _send_cmd and the first response A is claimed but not executing; treating that as
|
|
# "nobody to protect" let a queued request's Stop kill A mid-prefill.
|
|
orch = _orchestrator_for_ownership()
|
|
a_event = threading.Event()
|
|
b_event = threading.Event()
|
|
|
|
orch._claim_worker(a_event) # A sent its command and is in prefill
|
|
orch._claim_worker(b_event) # B is queued behind it
|
|
|
|
orch.reset_generation_state(b_event)
|
|
assert not orch._cancel_event.is_set(), "B must not reset A during prefill"
|
|
|
|
# A's own Stop still works before any token has arrived.
|
|
orch.reset_generation_state(a_event)
|
|
assert orch._cancel_event.is_set()
|
|
|
|
|
|
def test_the_oldest_claim_is_the_one_the_worker_is_prefilling():
|
|
# The command queue is FIFO, so with nothing answering the oldest claim is the executor.
|
|
orch = _orchestrator_for_ownership()
|
|
a_event = threading.Event()
|
|
b_event = threading.Event()
|
|
orch._claim_worker(a_event)
|
|
orch._claim_worker(b_event)
|
|
orch._release_worker(a_event)
|
|
|
|
orch.reset_generation_state(b_event)
|
|
assert orch._cancel_event.is_set(), "B is now the oldest claim"
|
|
|
|
|
|
def test_claim_order_matches_send_order_under_concurrent_dispatch():
|
|
# _owns_worker reads claim order to decide who is prefilling, so a claim not atomic with the
|
|
# enqueue can put A first in the list while B is first in the subprocess queue: stopping A kills B.
|
|
_route_gate()
|
|
orch_mod = pytest.importorskip(
|
|
"core.inference.orchestrator", reason = "inference stack not installed"
|
|
)
|
|
orch = orch_mod.InferenceOrchestrator.__new__(orch_mod.InferenceOrchestrator)
|
|
orch._active_cancel_events = []
|
|
orch._executing_cancel_events = []
|
|
orch._active_cancel_lock = threading.Lock()
|
|
orch._send_order_lock = threading.Lock()
|
|
|
|
sent: list = []
|
|
barrier = threading.Barrier(4)
|
|
|
|
def worker(ev):
|
|
barrier.wait(timeout = 10)
|
|
with orch._send_order_lock:
|
|
orch._claim_worker(ev)
|
|
# Stand in for _send_cmd: the enqueue must not be separable from the claim.
|
|
sent.append(ev)
|
|
|
|
events = [threading.Event() for _ in range(4)]
|
|
threads = [threading.Thread(target = worker, args = (e,)) for e in events]
|
|
for t in threads:
|
|
t.start()
|
|
for t in threads:
|
|
t.join(timeout = 30)
|
|
|
|
assert orch._active_cancel_events == sent, "claim order must equal send order"
|