mirror of
https://github.com/unslothai/unsloth.git
synced 2026-10-19 17:06:43 +00:00
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>
355 lines
13 KiB
Python
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()
|