unsloth/studio/backend/studio_mcp/video_queue.py
Etherl 21e201bfad
Studio: MCP agents choose how and where a model loads, preview the load, and queue videos (#14090)
Includes #14088 (Studio: add an Always choice to Keep multiple models
loaded, track every model on the GPU, and report every model a load
unloads) and #14089 (Studio: MCP agents follow long work in one call and
see every model a load unloads); review the last 40 commits
(005cf16930..HEAD, one of them from pre-commit.ci: `git log --no-merges
53c03f7716..HEAD`), leaving out the merges of #14089's branch. The
partial-variant refusal skips a variant `/api/hub/download-status`
reports in flight, so this PR adds that one read-only route to the MCP
forward allowlist (00e364fdcf); the PR stacked on this one widens it to
the download routes.

## Summary

Over MCP, an agent could load a model but not say where it should run,
could not see what a load would download or unload before starting it,
and had to retry `generate_video` itself whenever Unsloth Studio was
already making a video. `load_model` now takes placement and memory
options, a new read-only `plan_load` tool previews a load,
`unload_model(kind="all")` frees every slot in one call, and
`generate_video` queues a clip that Unsloth Studio is too busy for.

## Changes

### `load_model` placement

- Image and video loads take `memory_mode`, `speed_mode` and `h3_task`
(video only), plus `transformer_quant` and `text_encoder_quant` (image
only). They are sent to both the download plan and the load. If an older
Unsloth Studio answers the plan with 422, the plan is asked again
without them.
- Chat, TTS and audio loads take `device` (`auto`/`cpu`/`gpu`),
`alongside`, `kv_cache_type` and `gpu_layers`. `device="cpu"` on a
llama-server GGUF sends `gpu_memory_mode="manual"`, `gpu_layers=0` and
`speculative_type="off"`. `audio_device` is sent only for tts and audio,
never for `kind="llm"`.
- `device="cpu"` with `kind="llm"` and a non-GGUF model is refused, as
are a `gpu_layers` value that contradicts `device` and a placement or
media option passed to a kind that ignores it. These are refused before
any request goes out.
- Under Keep multiple models loaded set to Always, a `device="cpu"` load
next to a serving chat model sends `alongside=true` automatically, so
the load is not refused for replacing that model.
- `LoadResult.placement` reports where the model ended up (`device`,
plus `memory_mode`, `offload_policy` and the quant fields for image and
video). `alongside_applied` says whether the chat model that was serving
before the load still serves. `studio_status` reports the same
`placement` on the image and video slots.
- A load with different placement fields no longer attaches to an
in-flight load of the same model.
- A GGUF variant with only part of its files on disk is refused with
"still downloading or incomplete" instead of failing later with a
misleading memory error. It is not refused while Unsloth Studio is
downloading it (`/api/hub/download-status`) or while an image or video
load is downloading it.

### `plan_load`

New read-only tool that loads and downloads nothing. It returns:

- `variants`: the GGUF repo's quantizations, each with its size and
whether it is downloaded or partial.
- `download_bytes`: bytes still to download (0 when nothing is left),
from the media download plan for image and video.
- `est_gpu_bytes`: the GPU estimate, for a downloaded GGUF chat model.
- `residents`, `multi_model_mode` and `would_evict`.

Under Off the eviction prediction is exact when it does not depend on
where Unsloth Studio places the load. It is an upper bound
(`would_evict_exact: false`) when it does: unloading another kind of
model (it happens only when the load reaches the GPU, which no setting
guarantees), an unreadable slot status, a model only held in memory, or
any load of a repo already resident (it may load again with another
context, device, cache type, variant or build). Under When they fit it
is always an upper bound. Under Always nothing is unloaded and
`would_refuse` says whether the load may be refused. Another variant of
a resident GGUF repo counts as a new load.

### `unload_model(kind="all")`

Unloads every chat slot, then the image, video and speech-to-text
models, and lists them under `unloaded` as `{kind, model}`. Kept chat
models are unloaded before the primary so none is promoted in between,
and models held in memory behind the active one are unloaded too. The
embedding model stays loaded. Because `unloaded` is a bool for one kind
and a list for `"all"`, the tool now publishes a combined output schema.

### Video queue

`studio_mcp/video_queue.py` queues a `generate_video` call that Unsloth
Studio refuses with "A video generation is already in progress." and
returns a `vq_...` id with `queue_position` and `eta_s`. A background
task per account submits clips in order once the previous video ends.
While it waits, it handles `gpu_busy`/`Retry-After` and network errors,
and settles a clip as `failed` with the route's reason when Unsloth
Studio refuses it for another reason. `get_job` and `cancel` accept the
queue id, and a clip can be withdrawn until its POST starts. The queue
is per account, holds at most 32 waiting clips, and lives only as long
as the MCP server process, so a restart drops clips that have not
started.

`studio/MCP.md` lists `plan_load` and the `kind="all"` behavior.

## Testing

New tests: `test_mcp_tools_placement.py`, `test_mcp_tools_plan_load.py`
and `test_mcp_video_queue.py`. `test_mcp_server.py`,
`test_mcp_no_host_paths.py`, `test_mcp_tools_status.py`,
`test_mcp_tools_loading.py` and `test_mcp_tools_video.py` were updated
for the new tool, the slot `placement` field and the queue.

```bash
cd studio/backend
python -m pytest tests/test_mcp_tools_placement.py tests/test_mcp_tools_plan_load.py tests/test_mcp_video_queue.py tests/test_mcp_server.py tests/test_mcp_no_host_paths.py tests/test_mcp_tools_status.py tests/test_mcp_tools_loading.py tests/test_mcp_tools_video.py -q
```

Result on the head of this branch: every `tests/test_mcp*.py` file and
`tests/test_multi_model_always.py` pass (1,550 passed, 3 skipped) except
three `test_mcp_images.py` tests that fail the same way on a clean
`main` in this environment.

## Not in this PR

- The `[multi_model_always]` error hint alternatives and the
INSTRUCTIONS sentence about `device="cpu"`, `alongside`,
`unload_model(kind="all")` and `plan_load` wait for the MCP errors PR,
which owns `errors.py` and INSTRUCTIONS.

## Follow-ups

- Bound the video queue's POST of a queued clip. A timed-out POST may
still have been accepted, so retrying it could submit the clip twice;
this needs an idempotency key on `/v1/videos` first. Every MCP POST has
no timeout today.
- Prune the per-account video queue state (an admission lock and a few
small entries per account that used the queue) once it is idle, without
breaking admission order.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Etherl <mrmrmidoessam@gmail.com>
Co-authored-by: Daniel Han <23090290+danielhanchen@users.noreply.github.com>
2026-10-18 18:35:30 -07:00

355 lines
13 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
"""Videos queued over MCP. Unsloth Studio makes one video at a time and refuses a second while one runs, so an agent submitting a shot list would have to retry each clip itself. generate_video instead queues a refused clip here, in order, and a background task per account submits each one once the video before it ends; get_job and cancel take the queue id.
The queue lives only as long as the Unsloth Studio MCP server process: a restart drops every queued video that has not started. It is per account, shared by that account's keys, and an id from another account reads as unknown."""
from __future__ import annotations
import asyncio
import contextlib
import secrets
import time
from collections import OrderedDict, deque
from dataclasses import dataclass, field
from typing import Any, Optional, Union
from urllib.parse import quote
import httpx
from fastmcp.exceptions import ToolError
from studio_mcp.caller import Caller
from studio_mcp.errors import raise_for_route
from studio_mcp.forward import forward, parse_json
from studio_mcp.outputs import JobStatus, VideoJobRef
from studio_mcp.tools import as_dict, number, opt_text, try_json
# core/inference/video_families.py VIDEO_GENERATION_BUSY_MSG; a test pins the two together.
BUSY_MESSAGE = "A video generation is already in progress."
PREFIX = "vq_"
POLL_S = 2.0
RETRY_S = 5.0
MAX_MISSES = 15
READ_S = 15.0
MAX_RETRY_AFTER_S = 120.0
CAPACITY = 64
MAX_WAITING = 32
RUNNING = ("queued", "in_progress")
PROGRESS = "/api/inference/video/generate-progress"
@dataclass
class QueuedVideo:
id: str
account_id: str
caller: Caller = field(repr = False)
body: dict = field(repr = False)
hints: Optional[dict] = field(default = None, repr = False)
# queued, submitted, failed or cancelled.
status: str = "queued"
video_id: Optional[str] = None
error: Optional[str] = None
# True while its POST is in flight, when it can no longer be withdrawn.
posting: bool = False
enqueued_at: float = field(default_factory = time.monotonic)
_items: "OrderedDict[str, QueuedVideo]" = OrderedDict()
_waiting: dict[str, deque] = {}
# account -> (video id, monotonic submission time) of the video this account's queue waits on.
_running: dict[str, tuple[str, float]] = {}
_admission: dict[str, asyncio.Lock] = {}
# account -> seconds the last video in this queue took.
_clip_s: dict[str, float] = {}
# Held here so each pump outlives the tool call that started it.
_pumps: dict[str, asyncio.Task] = {}
def is_queue_id(value: Any) -> bool:
return isinstance(value, str) and value.startswith(PREFIX)
def _video_path(video_id: str) -> str:
return f"/v1/videos/{quote(video_id, safe = '')}"
def _json(response: httpx.Response) -> dict:
try:
return as_dict(parse_json(response.content))
except (ValueError, UnicodeDecodeError):
return {}
def _is_busy(response: httpx.Response) -> bool:
if response.status_code != 409:
return False
error = _json(response).get("error")
return (
isinstance(error, dict)
and error.get("code") is None
and error.get("message") == BUSY_MESSAGE
)
def _ended(item: QueuedVideo) -> bool:
# A submitted video runs until the next one starts (one at a time), so only the one this
# account's queue still waits on may be running; its queue id must keep working until then.
if item.status == "queued" or item.posting:
return False
running = _running.get(item.account_id)
return not (item.status == "submitted" and running and running[0] == item.video_id)
def _evict() -> None:
while len(_items) > CAPACITY:
oldest = next((k for k, item in _items.items() if _ended(item)), None)
if oldest is None:
return
_items.pop(oldest)
def _enqueue(caller: Caller, body: dict, hints: Optional[dict]) -> QueuedVideo:
waiting = _waiting.setdefault(caller.account_id, deque())
if len(waiting) >= MAX_WAITING:
raise ToolError(
f"{MAX_WAITING} videos are already queued for this account in Unsloth Studio; "
"wait for one to start or cancel one."
)
if sum(len(queue) for queue in _waiting.values()) >= CAPACITY:
raise ToolError(
f"{CAPACITY} videos are already queued in Unsloth Studio; try again once some have started."
)
item = QueuedVideo(PREFIX + secrets.token_urlsafe(12), caller.account_id, caller, body, hints)
waiting.append(item)
_items[f"{item.account_id}:{item.id}"] = item
_evict()
return item
def _ensure_pump(account_id: str) -> None:
task = _pumps.get(account_id)
if task is None or task.done():
_pumps[account_id] = asyncio.get_running_loop().create_task(_pump(account_id))
def _settle(
item: QueuedVideo,
status: str,
*,
error: Optional[str] = None,
) -> None:
waiting = _waiting.get(item.account_id)
if waiting and item in waiting:
waiting.remove(item)
item.status, item.error, item.posting = status, error, False
async def _wait_for_running(caller: Caller) -> None:
"""Until the video this queue waits on is no longer queued or in progress (or gone)."""
seen, misses = False, 0
while (running := _running.get(caller.account_id)) is not None:
video_id, submitted_at = running
try:
# Bounded: a stalled read counts as a failed one rather than holding the queue forever.
read = await asyncio.wait_for(try_json(caller, _video_path(video_id)), READ_S)
except asyncio.TimeoutError:
read = None
job = as_dict(read)
# A failed read is no proof the video ended: only a run of them (it is gone) ends the wait.
misses = 0 if isinstance(read, dict) else misses + 1
if job.get("status") in RUNNING or 0 < misses < MAX_MISSES:
seen = seen or job.get("status") in RUNNING
await asyncio.sleep(POLL_S)
continue
# Only a video this wait saw running gives a clip time; an old one would inflate every ETA.
if job.get("status") == "completed" and seen:
_clip_s[caller.account_id] = time.monotonic() - submitted_at
if _running.get(caller.account_id) == running:
_running.pop(caller.account_id)
def _retry_after(response: httpx.Response) -> Optional[float]:
# gpu_busy comes flat ({error, retry_after}) or inside detail / an OpenAI error.
body = _json(response) if response.status_code in (409, 503) else {}
busy = next(
(
c
for c in (body, body.get("detail"), body.get("error"))
if isinstance(c, dict) and "gpu_busy" in (c.get("error"), c.get("code"))
),
None,
)
if busy is None:
return None
try:
seconds = float(response.headers.get("retry-after") or busy.get("retry_after"))
except (TypeError, ValueError):
seconds = RETRY_S
return min(max(seconds, 0.0), MAX_RETRY_AFTER_S)
async def _submit(item: QueuedVideo) -> Optional[float]:
"""POST the head once. Returns how long to wait before trying again, or None once it is settled."""
item.posting = True
try:
response = await forward(
item.caller, "POST", "/v1/videos", json_body = item.body, hub_header = True
)
except httpx.HTTPError:
# Unsloth Studio restarting or slow to answer: the clip stays queued.
return RETRY_S
finally:
item.posting = False
if _is_busy(response):
return RETRY_S
delay = _retry_after(response)
if delay is not None:
return delay
try:
job = raise_for_route(response, hints = item.hints)
except ToolError as exc:
_settle(item, "failed", error = str(exc))
return None
video_id = opt_text(as_dict(job).get("id"))
if video_id is None:
_settle(item, "failed", error = "Unsloth Studio returned no video job")
return None
item.video_id = video_id
_settle(item, "submitted")
_running[item.account_id] = (video_id, time.monotonic())
return None
async def _pump(account_id: str) -> None:
while True:
caller = None
while waiting := _waiting.get(account_id):
head = waiting[0]
caller = head.caller
try:
await _wait_for_running(head.caller)
if head.status != "queued" or not waiting or waiting[0] is not head:
continue
delay = await _submit(head)
if delay is not None:
await asyncio.sleep(delay)
except asyncio.CancelledError:
raise
except Exception:
_settle(head, "failed", error = "Unsloth Studio could not start the queued video.")
if caller is None:
return
# Follow the last clip it started to the end, so its record can be dropped once it is done.
with contextlib.suppress(Exception):
await _wait_for_running(caller)
if not _waiting.get(account_id):
return
async def post_or_queue(
caller: Caller,
body: dict,
*,
hints: Optional[dict] = None,
) -> Union[dict, VideoJobRef]:
"""POST the video, or queue it when Unsloth Studio is busy with another video (or this account already has videos waiting, to keep their order). Returns the route's job, or the queued video's reference."""
# One admission at a time per account: a second call must not start while the first, refused as
# busy, is still on its way into the queue, or it would overtake it.
async with _admission.setdefault(caller.account_id, asyncio.Lock()):
return await _admit(caller, body, hints)
async def _admit(caller: Caller, body: dict, hints: Optional[dict]) -> Union[dict, VideoJobRef]:
if not _waiting.get(caller.account_id):
response = await forward(caller, "POST", "/v1/videos", json_body = body, hub_header = True)
if not _is_busy(response):
job = raise_for_route(response, hints = hints)
video_id = opt_text(as_dict(job).get("id"))
if video_id:
_running[caller.account_id] = (video_id, time.monotonic())
return job
item = _enqueue(caller, body, hints)
_ensure_pump(caller.account_id)
position, eta = await _position_eta(item)
return VideoJobRef.from_route(
body, id = item.id, status = "queued", queue_position = position, eta_s = eta
)
async def _position_eta(item: QueuedVideo) -> tuple[Optional[int], Optional[float]]:
waiting = _waiting.get(item.account_id) or deque()
if item not in waiting:
return None, None
position = waiting.index(item) + 1
try:
# Bounded, like every status read here: a stalled one leaves the ETA unknown.
return position, await asyncio.wait_for(_eta(item.caller, position), READ_S)
except Exception:
return position, None
async def _eta(caller: Caller, position: int) -> Optional[float]:
"""remaining(running) + (position - 1) * clip; None when Unsloth Studio gives nothing to go on."""
running = _running.get(caller.account_id)
if running is None:
return None
video_id, submitted_at = running
elapsed = time.monotonic() - submitted_at
remaining = None
progress = as_dict(await try_json(caller, PROGRESS))
if progress.get("active") is True:
remaining = number(progress.get("eta_seconds"))
if remaining is None:
percent = number(as_dict(await try_json(caller, _video_path(video_id))).get("progress"))
if percent is None or not 0 < percent < 100:
return None
remaining = elapsed * (100 - percent) / percent
clip = _clip_s.get(caller.account_id) or (elapsed + remaining)
return round(max(remaining, 0.0) + (position - 1) * clip, 1)
def lookup(account_id: str, queue_id: str) -> QueuedVideo:
item = _items.get(f"{account_id}:{queue_id}")
if item is None:
raise ToolError("No such video job")
return item
async def job_status(item: QueuedVideo) -> JobStatus:
"""get_job's answer for a queue id that has not become a real video."""
# Read before the await: the queue can start the item while its position is being priced.
status = item.status
position, eta = await _position_eta(item) if status == "queued" else (None, None)
return JobStatus(
kind = "video",
id = item.id,
status = status,
queue_position = position,
eta_s = eta,
error = item.error,
)
def cancel(account_id: str, queue_id: str) -> tuple[bool, str]:
item = lookup(account_id, queue_id)
if item.video_id:
return (
False,
f"It already started as {item.video_id}; cancel(kind='video') stops the running video.",
)
if item.posting:
return False, "It is being started now; cancel(kind='video') stops the running video."
if item.status != "queued":
return False, f"The queued video already {item.status}."
_settle(item, "cancelled")
return True, "Removed the queued video before it started."
def _reset() -> None:
"""Test hook."""
for task in _pumps.values():
with contextlib.suppress(RuntimeError): # its event loop may be closed already
task.cancel()
for state in (_items, _waiting, _running, _clip_s, _pumps, _admission):
state.clear()