unsloth/studio/backend/studio_mcp/forward.py
Etherl 21f02a8da7
Studio: MCP agents upload datasets, load what they trained, and download models ahead of time (#14107)
Includes #14088, #14089 and #14090; review the last 24 commits
(7cbecec5e6..HEAD: `git log --no-merges 06a089d2d1..HEAD`), leaving out
the merges of #14090's branch.

## Summary

Over MCP, an agent could start training in Unsloth Studio, but it could
not upload its own data, load the model it had trained, or download the
next model ahead of time. This PR adds dataset uploads, loading a
checkpoint or export by name, a resolved `validate_only` echo, TTS
fine-tuning guidance, and a `download_model` tool backed by Unsloth
Studio's download manager.

## Changes

### Datasets

- `datasets(action="upload", filename, data_base64 | rows)` sends a file
to `/api/hub/datasets/upload`. It returns a `ref:` handle for
`config.local_datasets`, never the stored path the route returns.
- `datasets(action="upload_images", name, images, captions)` sends
captioned images to the diffusion dataset route. Every image gets a
fresh file stem, so a later upload never replaces an earlier one. Images
past Unsloth Studio's upload limit are refused before anything is sent,
with a note to split them across calls. It returns the dataset name that
`start_training(kind="diffusion")` takes as `data_dir`.

### Loading what you trained

- `load_model(checkpoint="<run>" | "<run>/checkpoint-N")` resolves the
name through `checkpoints.resolve`. `load_model(export="<name>")`
resolves it under the exports folder, and `variant` picks a GGUF
export's quantization.
- Both load through a minted `ref:` handle and report the name back,
never a path or the handle. `evicted_slots` entries get their names back
the same way, and so does the `get_job(kind="load")` result of a load
that outlasted `wait_s`. `unload_model(model=<that name>)` finds it.
- `is_lora` follows the run's PEFT type, passed through #14090's
`is_lora` kwarg. The load route detects LoRA itself, so a full fine-tune
is not forced to load as LoRA.
- A GGUF export loads as its folder plus the quantization in its file
name, or the `variant` passed, so `device="cpu"` places it as GGUF. An
export whose file name has no quantization needs `variant` before it can
be placed. A checkpoint is refused for `device="cpu"`.
- `checkpoint` and `export` are refused for `kind` image, video and stt.
- Unsloth Studio serves a trained load under its `ref:` handle, so the
field text says to chat with it by leaving `chat`'s `model` unset.

### `start_training`

- `validate_only` now returns the resolved config, with every path and
secret dropped.
- Refusals name the model as the caller sent it, never the host path a
`ref:` handle resolves to.
- It runs the trainer's audio-type detection. GGUF, audio.cpp and native
TTS ids are refused, with the list of trainable bases. It sets
`is_dataset_audio` for codec TTS: Orpheus/SNAC, CSM, Spark/BiCodec and
OuteTTS/DAC. A real start runs the same check, so it refuses the same
models and sends `is_dataset_audio` for codec TTS.
- The docstring names the trainable TTS bases:
`unsloth/orpheus-3b-0.1-ft`, `sesame/csm-1b`, `unsloth/Spark-TTS-0.5B`
and `unsloth/Llama-OuteTTS-1.0-1B`. It covers:
- the columns (audio + text, optional speaker_id) and the
`custom_format_mapping` roles;
  - CUDA only;
  - that GGUF/audio.cpp TTS (Qwen3-TTS, Breeze) can't be trained;
- the one-GPU order main uses: training unloads image and video models,
and unloads chat or TTS only when they don't fit next to it. A 409 comes
back while inference is running. Then export →
`load_model(checkpoint/export)` → `generate_audio`.
- Agents follow training with `get_job(kind="training", wait_s=50)`
instead of `studio_status`.

### Prefetch

- `ALLOWED_PREFIXES` gains exactly `/api/hub/download`. That covers
`/download`, `/download-status`, `/download-progress` and
`/download/cancel`, and nothing else under `/api/hub`.
- `download_model(model, kind, variant?)` starts a download without
loading anything. GGUF repos send `gguf_variant`, image and video send
their download-plan entries with `scope_id="diffusion"`, a native TTS or
audio model sends its audio download-plan entries (companion codec repos
included) with `scope_id="audio"`, and STT reuses `download_stt`. Image
and video downloads take the same options `load_model` takes
(`memory_mode`, `speed_mode`, `transformer_quant`, `text_encoder_quant`,
`h3_task`), so the plan stages the files the load will read.
- The id carries each start's `generation`, so
`cancel(kind="model_download", id)` (through `/download/cancel`) stops
only the run `download_model` started. `get_job(kind="download", id)`
reads `/download-status` and works with `wait_s`.
- An image or video download that fails part-way stops the parts it
already started. A cancel stops every part before it reports a refusal.
- `repo:variant` shorthand downloads and waits on that variant. A native
speech prefetch under `kind="tts"` or `"audio"` serves a load of either
kind.
- K16 guard 2: `load_model` waits for a running Unsloth Studio download
of the same model or variant before #14090's partial-variant refusal.
The wait runs inside the load job (#14090 now runs every pre-load read
there), so it outlives `wait_s`: the call returns `loading` with a job
id, `get_job(kind="load")` names the download id while it waits, and the
load starts once the download ends. The wait is capped at an hour, after
which the load fails and names the download id. A managed download is
waited on, never refused. An attach to an in-flight load (guard 1) still
comes first, and an unmanaged partial variant is still refused.
- New outputs: `JobStatus.download` and `DownloadRef`.

`studio/MCP.md` gets the `download_model` row and updated `datasets` and
`start_training` rows.

## Testing

New tests: `test_mcp_tools_datasets_upload.py`,
`test_mcp_tools_datasets_images.py`,
`test_mcp_tools_training_validate.py`, `test_mcp_tools_download.py`,
`test_mcp_train_to_use.py` and `test_mcp_k16_guard_order.py`.
`test_mcp_server.py`, `test_mcp_forward.py`,
`test_mcp_no_host_paths.py`, `test_mcp_tool_contract.py` and the cancel,
jobs, placement, training and video tests were updated. #14090's
allowlist test now expects the whole download manager to be forwarded.
In #14090's `test_mcp_tools_plan_load.py`, the fake `download-status`
now reports only the partial Q8_0 as running: with guard 2, a
`load_model` waits on any running managed download, so a world where
every download is running made those loads wait out `wait_s`.

```bash
cd studio/backend
python -m pytest tests/test_mcp_*.py tests/test_gpu_arbiter.py tests/test_multi_model_always.py tests/test_multi_model_settings.py -q
```

Result on this head: 1,840 passed, 3 skipped.

GPU run on the head before the last two own commits (one device, real
Unsloth Studio on its own port, 91 MCP calls):

- **Train to use:**
- `datasets(upload)` with 40 rows returned a `ref:`, and `validate_only`
echoed the config with no paths.
- `unsloth/Qwen3-0.6B` trained for 10 steps (~57 s), followed with
`get_job(kind="training", wait_s)`.
- `load_model(checkpoint=<run>)` loaded it in 26 s, reported the name
only, and `chat` answered. `checkpoint-5` also loaded.
- `export_model(format="lora")`, then `get_job(kind="export")`, then
`load_model(export="<run>/lora")` and `chat` worked.
  - `unload_model(kind="all")` returned GPU memory to baseline.
- **TTS:** MOSS-TTS-Nano and Qwen3-TTS GGUF were refused with the
trainable list. Orpheus `validate_only` set `is_dataset_audio`.
- **Prefetch ("plan a film for a 24 GB card and prefetch everything",
GPU size stubbed):** zero calls went around MCP.
- `download_model` worked for Qwen3-4B-GGUF UD-Q4_K_XL, Z-Image-Turbo
and Wan2.2-TI2V-5B GGUF (already on disk, so they came back completed),
and audio.cpp TTS.
- FLUX.2-klein and LTX-2.3 (69 GB) were cancelled while running with
`cancel(kind="model_download")`.
- A `load_model` during a running download returned `loading` with a job
id and later finished.

No UI change.

## Not in this PR

- The INSTRUCTIONS prefetch sentence waits for the MCP errors PR
(#14081), which owns INSTRUCTIONS.
- After a checkpoint or export load, `studio_status` and
`unload_model(kind="all")` show its `ref:` handle (opaque, not a path)
instead of its name. That code is owned elsewhere; follow-up.
- GGUF and diffusion downloads report no percent, because
`gguf-download-progress` is left off the allowlist on purpose.
- A merged or LoRA export whose `save_directory` has only one level is
not found by name. Use `"<run>/<name>"`.
- Backend TTS refusals and an audio dataset builder are deferred.

## Follow-ups

- A safetensors repo whose id contains "gguf" is treated as GGUF by
`download_model` (no variant listed, so it is refused). This is the same
name-based rule #14090's `is_gguf` uses for loads, so prefetch and load
agree. Moving both to the listing signal is a follow-up.
- A `get_job(kind="load")` read in the same event-loop turn a trained
load finishes can still show its `ref:` handle, and a cancelled trained
load names the handle. The fix belongs in the shared load job runner.

---------

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:40:35 -07:00

107 lines
4.3 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
"""MCP tools reach Unsloth Studio through its own HTTP routes, in-process, as the API-key caller. Calling the route functions directly would skip the auth dependency, so the call would run as the owner and see host paths; going through the app means each call is authenticated, account-scoped and redacted exactly as the same request from the agent would be.
The in-process client is deliberately remote: a TEST-NET peer and an ``.invalid`` Host fail every loopback, LAN and keyless check, so a forwarded call can never use keyless access, whatever Unsloth Studio's keyless scope. Only the caller's key and, where a route reads it, the Hub token header are sent; nothing from the inbound request is copied."""
from __future__ import annotations
import asyncio
import json
from typing import Any, Optional
import httpx
from studio_mcp.caller import Caller
# RFC 5737 TEST-NET-1 and an RFC 6761 .invalid name: neither can be loopback, private or resolvable.
FORWARD_CLIENT = ("192.0.2.1", 0)
FORWARD_BASE_URL = "http://unsloth-mcp.invalid"
HF_TOKEN_HEADER = "X-Unsloth-HF-Token"
# The route families MCP tools use. Anything else, notably auth, sandbox and the local-person settings, is a programming error.
ALLOWED_PREFIXES = (
"/api/inference/",
"/v1/",
"/api/train/",
"/api/models/",
"/api/export/",
"/api/data-recipe/",
"/api/hub/datasets/",
"/api/hub/download",
"/api/hub/gguf-variants",
# Read only: load_model and plan_load check whether Unsloth Studio is still downloading a variant.
"/api/hub/download-status",
"/api/settings/embedding-model",
)
def checked_path(url: httpx.URL) -> str:
path = url.path
segments = path.split("/")
if not path.startswith(ALLOWED_PREFIXES) or any(s in (".", "..") for s in segments):
raise RuntimeError(f"Unsloth Studio MCP does not forward to {path!r}")
return path
def _headers(caller: Caller, hub_header: bool, content_type: Optional[str]) -> dict[str, str]:
headers = {"Authorization": f"Bearer {caller.token}"}
if hub_header and caller.hf_token:
headers[HF_TOKEN_HEADER] = caller.hf_token
if content_type:
headers["Content-Type"] = content_type
return headers
async def forward(
caller: Caller,
method: str,
path: str,
*,
params: Optional[dict[str, Any]] = None,
json_body: Any = None,
content: Optional[bytes] = None,
content_type: Optional[str] = None,
files: Any = None,
data: Optional[dict[str, Any]] = None,
hub_header: bool = False,
) -> httpx.Response:
"""Send one request to Unsloth Studio as ``caller`` and return the buffered response. ``content`` must be bytes, so httpx sets Content-Length: the upload routes answer a streamed body with 411."""
if content is not None and not isinstance(content, (bytes, bytearray)):
raise TypeError("forward() uploads need bytes")
# The route binds the account ContextVar in whatever task runs it; its own task keeps that out of the tool.
body = {"params": params, "json": json_body, "content": content, "files": files, "data": data}
task = asyncio.create_task(
_send(caller, method, path, body, _headers(caller, hub_header, content_type))
)
try:
return await task
except asyncio.CancelledError:
task.cancel()
raise
async def _send(
caller: Caller, method: str, path: str, body: dict[str, Any], headers: dict[str, str]
) -> httpx.Response:
transport = httpx.ASGITransport(app = caller.studio_app, client = FORWARD_CLIENT)
async with httpx.AsyncClient(
transport = transport, base_url = FORWARD_BASE_URL, timeout = None
) as client:
request = client.build_request(method, path, headers = headers, **body)
checked_path(request.url)
return await client.send(request)
def parse_json(body: bytes) -> Any:
"""Route JSON. ``/load`` and ``/unload`` pad a slow answer with leading spaces to keep tunnels open."""
return json.loads(body.strip())
def ndjson_last(body: bytes) -> Any:
"""The last JSON line of an NDJSON stream, which carries the result or the error."""
lines = [line for line in body.splitlines() if line.strip()]
if not lines:
raise ValueError("empty NDJSON body")
return json.loads(lines[-1])