diff --git a/COPYING b/COPYING
index cb7331bd35..4213fd700d 100644
--- a/COPYING
+++ b/COPYING
@@ -661,4 +661,4 @@ For more information on this, and how to apply and follow the GNU AGPL, see
.
Files under unsloth/*, tests/*, scripts/* are Apache 2.0 licensed.
-Files under studio/*, cli/* which is optional to install are AGPLv3 licensed.
\ No newline at end of file
+Files under studio/*, unsloth_cli/* which is optional to install are AGPLv3 licensed.
\ No newline at end of file
diff --git a/LICENSE b/LICENSE
index 8c3c43e374..e1600b0c03 100644
--- a/LICENSE
+++ b/LICENSE
@@ -188,7 +188,7 @@
Copyright [2024-] [Unsloth AI. Inc team, Daniel Han-Chen & Michael Han-Chen]
Files under unsloth/*, tests/*, scripts/* are Apache 2.0 licensed.
- Files under studio/*, cli/* which is optional to install are AGPLv3 licensed.
+ Files under studio/*, unsloth_cli/* which is optional to install are AGPLv3 licensed.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
diff --git a/README.md b/README.md
index 16ec872b64..12c12d58aa 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,8 @@ Run and train AI models with a unified local interface.
Documentation β’
Discord
-
+
+
Unsloth Studio lets you run and train models for text, [audio](https://unsloth.ai/docs/basics/text-to-speech-tts-fine-tuning), [embedding](https://unsloth.ai/docs/new/embedding-finetuning), [vision](https://unsloth.ai/docs/basics/vision-fine-tuning) and more. Available on Windows, Linux and macOS.
## β Features
@@ -43,38 +44,100 @@ Unsloth can be used in two ways: through **[Unsloth Studio](https://unsloth.ai/d
Unsloth Studio works on **Windows, Linux, WSL** and **macOS**.
* **CPU:** Supported for **chat inference only**
-* **NVIDIA GPUs:** Training works on RTX 30/40/50, Blackwell, DGX Spark, DGX Station and more
+* **NVIDIA:** Training works on RTX 30/40/50, Blackwell, DGX Spark, Station and more
* **macOS:** Currently supports chat only; **MLX training** is coming very soon
+* **AMD:** Chat works. Train with [Unsloth Core](#unsloth-core-code-based). Studio support is coming soon.
+* **Coming soon:** Training support for Apple MLX, AMD, and Intel.
* **Multi-GPU:** Available now, with a major upgrade on the way
-#### Windows, MacOS Linux or WSL:
-```
-git clone https://github.com/unslothai/unsloth.git
-cd unsloth
-pip install -e .
+#### MacOS, Linux or WSL Setup (One time):
+```bash
+pip install -U pip uv
+uv venv unsloth_studio
+source unsloth_studio/bin/activate
+uv pip install unsloth --torch-backend=auto
unsloth studio setup
unsloth studio -H 0.0.0.0 -p 8888
```
-Use our [Docker image](https://hub.docker.com/r/unsloth/unsloth) ```unsloth/unsloth``` container. Read our [Docker Guide](https://unsloth.ai/docs/get-started/install/docker).
-#### Git from source
+Then to launch every time:
+```bash
+source unsloth_studio/bin/activate
+unsloth studio -H 0.0.0.0 -p 8888
```
-git clone https://github.com/unslothai/unsloth.git
-cd unsloth
-pip install -e .
+
+#### Windows PowerShell (One time):
+```bash
+pip install -U pip uv
+uv venv unsloth_studio
+.\unsloth_studio\Scripts\activate
+uv pip install unsloth --torch-backend=auto
unsloth studio setup
unsloth studio -H 0.0.0.0 -p 8888
```
+Then to launch every time:
+```bash
+.\unsloth_studio\Scripts\activate
+unsloth studio -H 0.0.0.0 -p 8888
+```
+
+Use our [Docker image](https://hub.docker.com/r/unsloth/unsloth) ```unsloth/unsloth``` container. Read our [Docker Guide](https://unsloth.ai/docs/get-started/install/docker).
+
+#### Nightly Installation - MacOS, Linux or WSL Setup (One time):
+```bash
+pip install -U pip uv
+git clone --filter=blob:none https://github.com/unslothai/unsloth.git unsloth_studio
+cd unsloth_studio
+uv venv
+source .venv/bin/activate
+uv pip install -e . --torch-backend=auto
+unsloth studio setup
+unsloth studio -H 0.0.0.0 -p 8888
+```
+Then to launch every time:
+```bash
+cd unsloth_studio
+source .venv/bin/activate
+unsloth studio -H 0.0.0.0 -p 8888
+```
+
+#### Nightly Installation - Windows Powershell (One time):
+```bash
+pip install -U pip uv
+git clone --filter=blob:none https://github.com/unslothai/unsloth.git unsloth_studio
+cd unsloth_studio
+uv venv
+.\.venv\Scripts\activate
+uv pip install -e . --torch-backend=auto
+unsloth studio setup
+unsloth studio -H 0.0.0.0 -p 8888
+```
+Then to launch every time:
+```bash
+cd unsloth_studio
+.\.venv\Scripts\activate
+unsloth studio -H 0.0.0.0 -p 8888
+```
### Unsloth Core (code-based)
-#### Windows, Linux, WSL
+#### Linux, WSL
```bash
-pip install unsloth
+pip install -U pip uv
+uv venv unsloth_env
+source unsloth_env/bin/activate
+uv pip install unsloth --torch-backend=auto
+```
+#### Windows Powershell
+```bash
+pip install -U pip uv
+uv venv unsloth_env
+.\unsloth_env\Scripts\activate
+uv pip install unsloth --torch-backend=auto
```
For Windows, `pip install unsloth` works only if you have Pytorch installed. Read our [Windows Guide](https://unsloth.ai/docs/get-started/install/windows-installation).
You can use the same Docker image as Unsloth Studio.
#### AMD, Intel
-For RTX 50x, B200, 6000 GPUs: `pip install unsloth`. Read our guides for: [Blackwell](https://unsloth.ai/docs/blog/fine-tuning-llms-with-blackwell-rtx-50-series-and-unsloth) and [DGX Spark](https://unsloth.ai/docs/blog/fine-tuning-llms-with-nvidia-dgx-spark-and-unsloth).
+For RTX 50x, B200, 6000 GPUs: `uv pip install unsloth --torch-backend=auto`. Read our guides for: [Blackwell](https://unsloth.ai/docs/blog/fine-tuning-llms-with-blackwell-rtx-50-series-and-unsloth) and [DGX Spark](https://unsloth.ai/docs/blog/fine-tuning-llms-with-nvidia-dgx-spark-and-unsloth).
To install Unsloth on **AMD** and **Intel** GPUs, follow our [AMD Guide](https://unsloth.ai/docs/get-started/install/amd) and [Intel Guide](https://unsloth.ai/docs/get-started/install/intel).
## β¨ Free Notebooks
@@ -132,6 +195,11 @@ You can cite the Unsloth repo as follows:
```
If you trained a model with π¦₯Unsloth, you can use this cool sticker! Β
+### License
+Unsloth uses a dual-licensing model of Apache 2.0 and AGPL-3.0. The core Unsloth package remains licensed under **[Apache 2.0](https://github.com/unslothai/unsloth?tab=Apache-2.0-1-ov-file)**, while certain optional components, such as the Unsloth Studio UI are licensed under the open-source license **[AGPL-3.0](https://github.com/unslothai/unsloth?tab=AGPL-3.0-2-ov-file)**.
+
+This structure helps support ongoing Unsloth development while keeping the project open source and enabling the broader ecosystem to continue growing.
+
### Thank You to
- The [llama.cpp library](https://github.com/ggml-org/llama.cpp) that lets users run and save models with Unsloth
- The Hugging Face team and their libraries: [transformers](https://github.com/huggingface/transformers) and [TRL](https://github.com/huggingface/trl)
diff --git a/cli.py b/cli.py
index 0965b424e8..534babed2c 100644
--- a/cli.py
+++ b/cli.py
@@ -1,7 +1,7 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
-from cli import app
+from unsloth_cli import app
if __name__ == "__main__":
app()
diff --git a/pyproject.toml b/pyproject.toml
index 5a75495c56..eaa7d84f71 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -55,7 +55,7 @@ dependencies = [
]
[project.scripts]
-unsloth = "cli:app"
+unsloth = "unsloth_cli:app"
[tool.setuptools.dynamic]
version = {attr = "unsloth.models._utils.__version__"}
@@ -737,6 +737,6 @@ cu130-ampere-torch2100 = [
]
[project.urls]
-homepage = "http://www.unsloth.ai"
-documentation = "https://github.com/unslothai/unsloth"
+homepage = "https://unsloth.ai"
+documentation = "https://unsloth.ai/docs"
repository = "https://github.com/unslothai/unsloth"
diff --git a/studio/Unsloth_Studio_Colab.ipynb b/studio/Unsloth_Studio_Colab.ipynb
index 0338fc3a47..c6a5378b85 100644
--- a/studio/Unsloth_Studio_Colab.ipynb
+++ b/studio/Unsloth_Studio_Colab.ipynb
@@ -15,11 +15,14 @@
"To install Unsloth Studio on your local device, follow [our guide](https://unsloth.ai/docs/new/unsloth-studio/install). Unsloth Studio is licensed [AGPL-3.0](https://github.com/unslothai/unsloth/blob/main/studio/LICENSE.AGPL-3.0).\n",
"\n",
"### Unsloth Studio\n",
- "\n",
- "Train and run open models with [**Unsloth Studio**](https://unsloth.ai/docs/new/unsloth-studio/start). Installation may take 10 mins.\n",
- "\n",
- "[Features](https://unsloth.ai/docs/new/unsloth-studio#features) β’ [Quickstart](https://unsloth.ai/docs/new/unsloth-studio/start) β’ [Data Recipes](https://unsloth.ai/docs/new/unsloth-studio/data-recipe) β’ [Studio Chat](https://unsloth.ai/docs/new/unsloth-studio/chat) β’ [Export](https://unsloth.ai/docs/new/unsloth-studio/export)"
- ]
+ "\n",
+ "Train and run open models with [**Unsloth Studio**](https://unsloth.ai/docs/new/unsloth-studio/start). Currently, installation may take 30+ mins so use a newer GPU.\n",
+ "\n",
+ "\n",
+ "We are actively working on making Unsloth Studio install on Colab T4 GPUs faster.\n",
+ "\n",
+ "[Features](https://unsloth.ai/docs/new/unsloth-studio#features) β’ [Quickstart](https://unsloth.ai/docs/new/unsloth-studio/start) β’ [Data Recipes](https://unsloth.ai/docs/new/unsloth-studio/data-recipe) β’ [Studio Chat](https://unsloth.ai/docs/new/unsloth-studio/chat) β’ [Export](https://unsloth.ai/docs/new/unsloth-studio/export)"
+ ]
},
{
"cell_type": "markdown",
diff --git a/studio/backend/colab.py b/studio/backend/colab.py
index 7162b6d4c2..f2f56b7f17 100644
--- a/studio/backend/colab.py
+++ b/studio/backend/colab.py
@@ -9,6 +9,32 @@ Uses Colab's built-in proxy - no external tunneling needed!
from pathlib import Path
import sys
+
+def _bootstrap_studio_venv() -> None:
+ """Expose the Studio venv's site-packages to the current interpreter.
+
+ On Colab, notebook cells run outside the venv subshell. Instead of
+ installing the full stack into system Python, we prepend the venv's
+ site-packages so that packages like structlog, fastapi, etc. are
+ importable from notebook cells and take priority over system copies.
+ """
+ venv_lib = Path.home() / ".unsloth" / "studio" / ".venv" / "lib"
+ if not venv_lib.exists():
+ import warnings
+
+ warnings.warn(
+ f"Studio venv not found at {venv_lib.parent} -- run 'unsloth studio setup' first",
+ stacklevel = 2,
+ )
+ return
+ for sp in venv_lib.glob("python*/site-packages"):
+ sp_str = str(sp)
+ if sp_str not in sys.path:
+ sys.path.insert(0, sp_str)
+
+
+_bootstrap_studio_venv()
+
# Add backend to path early so local modules like loggers can be imported
backend_path = str(Path(__file__).parent)
if backend_path not in sys.path:
diff --git a/studio/backend/core/__init__.py b/studio/backend/core/__init__.py
index 051f6615a7..d8d95e2f1a 100644
--- a/studio/backend/core/__init__.py
+++ b/studio/backend/core/__init__.py
@@ -10,6 +10,16 @@ like unsloth, transformers, or torch before the version activation
code has a chance to run.
"""
+import sys
+from pathlib import Path
+
+# Ensure the backend directory is on sys.path so that bare "from utils.*"
+# imports used throughout the backend work when core is imported as a package
+# (e.g. from the CLI: "from studio.backend.core import ModelConfig").
+_backend_dir = str(Path(__file__).resolve().parent.parent)
+if _backend_dir not in sys.path:
+ sys.path.insert(0, _backend_dir)
+
__all__ = [
# Inference
"InferenceBackend",
diff --git a/studio/backend/core/export/worker.py b/studio/backend/core/export/worker.py
index 4f74f662ee..6af6ff1193 100644
--- a/studio/backend/core/export/worker.py
+++ b/studio/backend/core/export/worker.py
@@ -40,59 +40,25 @@ def _activate_transformers_version(model_name: str) -> None:
if backend_path not in sys.path:
sys.path.insert(0, backend_path)
- from utils.transformers_version import needs_transformers_5, _resolve_base_model
+ from utils.transformers_version import (
+ needs_transformers_5,
+ _resolve_base_model,
+ _ensure_venv_t5_exists,
+ _VENV_T5_DIR,
+ )
resolved = _resolve_base_model(model_name)
if needs_transformers_5(resolved):
- venv_t5 = os.path.join(
- os.path.expanduser("~"), ".unsloth", "studio", ".venv_t5"
- )
- if os.path.isdir(venv_t5):
- sys.path.insert(0, venv_t5)
- logger.info("Activated transformers 5.x from %s", venv_t5)
- else:
- # Fallback: pip install at runtime (slower, ~10-15s)
- logger.warning(".venv_t5 not found at %s β installing at runtime", venv_t5)
- import subprocess as sp
-
- os.makedirs(venv_t5, exist_ok = True)
- r1 = sp.run(
- [
- sys.executable,
- "-m",
- "pip",
- "install",
- "--target",
- venv_t5,
- "--no-deps",
- "transformers==5.3.0",
- ],
- stdout = sp.PIPE,
- stderr = sp.STDOUT,
+ if not _ensure_venv_t5_exists():
+ raise RuntimeError(
+ f"Cannot activate transformers 5.x: .venv_t5 missing at {_VENV_T5_DIR}"
)
- r2 = sp.run(
- [
- sys.executable,
- "-m",
- "pip",
- "install",
- "--target",
- venv_t5,
- "--no-deps",
- "huggingface_hub==1.3.0",
- ],
- stdout = sp.PIPE,
- stderr = sp.STDOUT,
- )
- if r1.returncode != 0 or r2.returncode != 0:
- raise RuntimeError(
- f"Failed to install transformers 5.x into {venv_t5}. "
- f"pip returncode: transformers={r1.returncode}, huggingface_hub={r2.returncode}"
- )
- sys.path.insert(0, venv_t5)
+ if _VENV_T5_DIR not in sys.path:
+ sys.path.insert(0, _VENV_T5_DIR)
+ logger.info("Activated transformers 5.x from %s", _VENV_T5_DIR)
# Propagate to child subprocesses (e.g. GGUF converter)
_pp = os.environ.get("PYTHONPATH", "")
- os.environ["PYTHONPATH"] = venv_t5 + (os.pathsep + _pp if _pp else "")
+ os.environ["PYTHONPATH"] = _VENV_T5_DIR + (os.pathsep + _pp if _pp else "")
else:
logger.info("Using default transformers (4.57.x) for %s", model_name)
diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py
index 64a7324adf..8054bd2a19 100644
--- a/studio/backend/core/inference/llama_cpp.py
+++ b/studio/backend/core/inference/llama_cpp.py
@@ -1173,50 +1173,113 @@ class LlamaCppBackend:
Handles formats like:
{"name":"web_search","arguments":{"query":"..."}}...
- Closing tag is optional (models sometimes omit it).
+ Closing tags (, , ) are all optional
+ since models frequently omit them.
"""
import re
tool_calls = []
- # Pattern 1: JSON inside tags (closing tag optional)
- for match in re.finditer(
- r"\s*(\{.*?\})\s*(?:)?", content, re.DOTALL
- ):
- try:
- obj = json.loads(match.group(1))
- tc = {
- "id": f"call_{len(tool_calls)}",
- "type": "function",
- "function": {
- "name": obj.get("name", ""),
- "arguments": obj.get("arguments", {}),
- },
- }
- if isinstance(tc["function"]["arguments"], dict):
- tc["function"]["arguments"] = json.dumps(
- tc["function"]["arguments"]
- )
- tool_calls.append(tc)
- except (json.JSONDecodeError, ValueError):
- pass
+
+ # Pattern 1: JSON inside tags.
+ # Use balanced-brace extraction that skips braces inside JSON strings.
+ for m in re.finditer(r"\s*\{", content):
+ brace_start = m.end() - 1 # position of the opening {
+ depth, i = 0, brace_start
+ in_string = False
+ while i < len(content):
+ ch = content[i]
+ if in_string:
+ if ch == "\\" and i + 1 < len(content):
+ i += 2 # skip escaped character
+ continue
+ if ch == '"':
+ in_string = False
+ elif ch == '"':
+ in_string = True
+ elif ch == "{":
+ depth += 1
+ elif ch == "}":
+ depth -= 1
+ if depth == 0:
+ break
+ i += 1
+ if depth == 0:
+ json_str = content[brace_start : i + 1]
+ try:
+ obj = json.loads(json_str)
+ tc = {
+ "id": f"call_{len(tool_calls)}",
+ "type": "function",
+ "function": {
+ "name": obj.get("name", ""),
+ "arguments": obj.get("arguments", {}),
+ },
+ }
+ if isinstance(tc["function"]["arguments"], dict):
+ tc["function"]["arguments"] = json.dumps(
+ tc["function"]["arguments"]
+ )
+ tool_calls.append(tc)
+ except (json.JSONDecodeError, ValueError):
+ pass
# Pattern 2: XML-style value
- # Closing optional
+ # All closing tags optional -- models frequently omit ,
+ # , and/or .
if not tool_calls:
- for match in re.finditer(
- r"\s*(.*?)\s*(?:)?",
- content,
- re.DOTALL,
- ):
- func_name = match.group(1)
- params_text = match.group(2)
+ # Step 1: Find all positions and extract their bodies.
+ # Body boundary: use only or next as a boundary because
+ # code parameter values can contain that literal string.
+ # After extracting, we trim a trailing if present.
+ func_starts = list(re.finditer(r"\s*", content))
+ for idx, fm in enumerate(func_starts):
+ func_name = fm.group(1)
+ body_start = fm.end()
+ # Hard boundaries: next
+ next_func = (
+ func_starts[idx + 1].start()
+ if idx + 1 < len(func_starts)
+ else len(content)
+ )
+ end_tag = re.search(r"", content[body_start:])
+ if end_tag:
+ body_end = body_start + end_tag.start()
+ else:
+ body_end = len(content)
+ body_end = min(body_end, next_func)
+ body = content[body_start:body_end]
+ # Trim trailing if present (it's the real closing tag)
+ body = re.sub(r"\s*\s*$", "", body)
+
+ # Step 2: Extract parameters from body.
+ # For single-parameter functions (the common case: code, command,
+ # query), use body end as the only boundary to avoid false matches
+ # on inside code strings.
arguments = {}
- for param_match in re.finditer(
- r"\s*(.*?)\s*",
- params_text,
- re.DOTALL,
- ):
- arguments[param_match.group(1)] = param_match.group(2)
+ param_starts = list(re.finditer(r"\s*", body))
+ if len(param_starts) == 1:
+ # Single parameter: value is everything from after the tag
+ # to end of body, trimming any trailing .
+ pm = param_starts[0]
+ val = body[pm.end() :]
+ val = re.sub(r"\s*\s*$", "", val)
+ arguments[pm.group(1)] = val.strip()
+ else:
+ for pidx, pm in enumerate(param_starts):
+ param_name = pm.group(1)
+ val_start = pm.end()
+ # Value ends at next if present
+ val = re.sub(r"\s*\s*$", "", val)
+ arguments[param_name] = val.strip()
+
tc = {
"id": f"call_{len(tool_calls)}",
"type": "function",
@@ -1273,10 +1336,11 @@ class LlamaCppBackend:
) -> Generator[str, None, None]:
"""Iterate over an httpx streaming response with cancel support.
- Uses a short read timeout on the stream so that cancel_event is
- checked at least every 0.5s, even if the model is slow to produce
- the next token. Without this, iter_text() blocks until the next
- chunk arrives and cancellation can take many seconds on large models.
+ Checks cancel_event between chunks and on ReadTimeout. The
+ cancel watcher in _stream_with_retry also calls response.close()
+ on cancel, which unblocks iter_text() once the response exists.
+ During normal streaming llama-server sends tokens frequently,
+ so the cancel check between chunks is the primary mechanism.
"""
text_iter = response.iter_text()
while True:
@@ -1301,24 +1365,85 @@ class LlamaCppBackend:
payload: dict,
cancel_event: Optional[threading.Event] = None,
):
- """Open an httpx streaming POST, retrying on ReadTimeout.
+ """Open an httpx streaming POST with cancel support.
- The short read timeout (0.5 s) that enables cancel-checking during
- streaming can also fire while waiting for the server to produce
- its first response bytes (e.g. a reasoning model thinking).
- This wrapper retries the connection until headers arrive or
- cancel_event is set.
+ Sends the request once with a long read timeout (120 s) so
+ prompt processing (prefill) can finish without triggering a
+ retry storm. The previous 0.5 s timeout caused duplicate POST
+ requests every half second, forcing llama-server to restart
+ processing each time.
+
+ A background watcher thread provides cancel by closing the
+ response when cancel_event is set. Limitation: httpx does not
+ allow interrupting a blocked read from another thread before
+ the response object exists, so cancel during the initial
+ header wait (prefill phase) only takes effect once headers
+ arrive. After that, response.close() unblocks reads promptly.
+ In practice llama-server prefill is 1-5 s for typical prompts,
+ during which cancel is deferred -- still much better than the
+ old retry storm which made prefill slower.
"""
- while True:
+ if cancel_event is not None and cancel_event.is_set():
+ raise GeneratorExit
+
+ # Background watcher: close the response if cancel is requested.
+ # Only effective after response headers arrive (httpx limitation).
+ _cancel_closed = threading.Event()
+ _response_ref: list = [None]
+
+ def _cancel_watcher():
+ while not _cancel_closed.is_set():
+ if cancel_event.wait(timeout = 0.3):
+ # Cancel requested. Keep polling until the response object
+ # exists so we can close it, or until the main thread
+ # finishes on its own (_cancel_closed is set in finally).
+ while not _cancel_closed.is_set():
+ r = _response_ref[0]
+ if r is not None:
+ try:
+ r.close()
+ return
+ except Exception as e:
+ logger.debug(
+ f"Error closing response in cancel watcher: {e}"
+ )
+ # Response not created yet -- wait briefly and retry
+ _cancel_closed.wait(timeout = 0.1)
+ return
+
+ watcher = None
+ if cancel_event is not None:
+ watcher = threading.Thread(
+ target = _cancel_watcher, daemon = True, name = "prefill-cancel"
+ )
+ watcher.start()
+
+ try:
+ # Long read timeout so prefill (prompt processing) can finish
+ # without triggering a retry storm. Cancel during both
+ # prefill and streaming is handled by the watcher thread
+ # which closes the response, unblocking any httpx read.
+ prefill_timeout = httpx.Timeout(
+ connect = 30,
+ read = 120.0,
+ write = 10,
+ pool = 10,
+ )
+ with client.stream(
+ "POST", url, json = payload, timeout = prefill_timeout
+ ) as response:
+ _response_ref[0] = response
+ if cancel_event is not None and cancel_event.is_set():
+ raise GeneratorExit
+ yield response
+ return
+ except (httpx.ReadError, httpx.RemoteProtocolError, httpx.CloseError):
+ # Response was closed by the cancel watcher
if cancel_event is not None and cancel_event.is_set():
raise GeneratorExit
- try:
- with client.stream("POST", url, json = payload) as response:
- yield response
- return
- except httpx.ReadTimeout:
- # Server still thinking -- retry
- continue
+ raise
+ finally:
+ _cancel_closed.set()
def generate_chat_completion(
self,
@@ -1371,8 +1496,9 @@ class LlamaCppBackend:
in_thinking = False
try:
- # Use a short read timeout so we can check cancel_event
- # frequently instead of blocking indefinitely on slow models.
+ # _stream_with_retry uses a 120 s read timeout so prefill
+ # can finish. Cancel during streaming is handled by the
+ # watcher thread (closes the response on cancel_event).
stream_timeout = httpx.Timeout(connect = 10, read = 0.5, write = 10, pool = 10)
with httpx.Client(timeout = stream_timeout) as client:
with self._stream_with_retry(
@@ -1468,7 +1594,10 @@ class LlamaCppBackend:
stop: Optional[list[str]] = None,
cancel_event: Optional[threading.Event] = None,
enable_thinking: Optional[bool] = None,
- max_tool_iterations: int = 5,
+ max_tool_iterations: int = 10,
+ auto_heal_tool_calls: bool = True,
+ tool_call_timeout: int = 300,
+ session_id: Optional[str] = None,
) -> Generator[dict, None, None]:
"""
Agentic loop: let the model call tools, execute them, and continue.
@@ -1533,16 +1662,45 @@ class LlamaCppBackend:
tool_calls = message.get("tool_calls")
# Fallback: detect tool calls embedded as XML/text in content
- # Some models output XML instead of structured tool_calls
+ # Some models output XML instead of structured tool_calls,
+ # or bare tags without wrapper.
content_text = message.get("content", "") or ""
- if not tool_calls and "" in content_text:
+ if (
+ auto_heal_tool_calls
+ and not tool_calls
+ and ("" in content_text or " blocks since they
+ # can contain arbitrary content including code.
import re
+ # Strip ... blocks (greedy inside)
content_text = re.sub(
- r".*?(?:|$)",
+ r".*?",
+ "",
+ content_text,
+ flags = re.DOTALL,
+ )
+ # Strip unterminated ... to end
+ content_text = re.sub(
+ r".*$",
+ "",
+ content_text,
+ flags = re.DOTALL,
+ )
+ # Strip bare ... blocks
+ content_text = re.sub(
+ r".*?",
+ "",
+ content_text,
+ flags = re.DOTALL,
+ )
+ # Strip unterminated bare to end
+ content_text = re.sub(
+ r".*$",
"",
content_text,
flags = re.DOTALL,
@@ -1569,7 +1727,10 @@ class LlamaCppBackend:
try:
arguments = json.loads(raw_args)
except (json.JSONDecodeError, ValueError):
- arguments = {"query": raw_args}
+ if auto_heal_tool_calls:
+ arguments = {"query": raw_args}
+ else:
+ arguments = {"raw": raw_args}
else:
arguments = raw_args
@@ -1596,10 +1757,33 @@ class LlamaCppBackend:
status_text = f"Calling: {tool_name}"
yield {"type": "status", "text": status_text}
+ # Emit tool_start so the frontend can record inputs
+ yield {
+ "type": "tool_start",
+ "tool_name": tool_name,
+ "tool_call_id": tc.get("id", ""),
+ "arguments": arguments,
+ }
+
# Execute the tool
- result = execute_tool(
- tool_name, arguments, cancel_event = cancel_event
+ _effective_timeout = (
+ None if tool_call_timeout >= 9999 else tool_call_timeout
)
+ result = execute_tool(
+ tool_name,
+ arguments,
+ cancel_event = cancel_event,
+ timeout = _effective_timeout,
+ session_id = session_id,
+ )
+
+ # Emit tool_end so the frontend can record outputs
+ yield {
+ "type": "tool_end",
+ "tool_name": tool_name,
+ "tool_call_id": tc.get("id", ""),
+ "result": result,
+ }
# Append tool result to conversation
tool_msg = {
@@ -1651,7 +1835,30 @@ class LlamaCppBackend:
if stop:
stream_payload["stop"] = stop
+ import re as _re_final
+
+ # Closed blocks only -- safe to strip mid-stream without shrinking later.
+ _TOOL_CLOSED_PATTERNS = [
+ _re_final.compile(r".*?", _re_final.DOTALL),
+ _re_final.compile(r".*?", _re_final.DOTALL),
+ ]
+ # Open-ended patterns strip from an opening tag to end-of-string.
+ # Only applied on the final flush to avoid non-monotonic shrinking.
+ _TOOL_ALL_PATTERNS = _TOOL_CLOSED_PATTERNS + [
+ _re_final.compile(r".*$", _re_final.DOTALL),
+ _re_final.compile(r".*$", _re_final.DOTALL),
+ ]
+
+ def _strip_tool_markup(text: str, *, final: bool = False) -> str:
+ if not auto_heal_tool_calls:
+ return text
+ patterns = _TOOL_ALL_PATTERNS if final else _TOOL_CLOSED_PATTERNS
+ for pat in patterns:
+ text = pat.sub("", text)
+ return text.strip() if final else text
+
cumulative = ""
+ _last_emitted = ""
in_thinking = False
has_content_tokens = False
reasoning_text = ""
@@ -1683,7 +1890,12 @@ class LlamaCppBackend:
if in_thinking:
if has_content_tokens:
cumulative += ""
- yield {"type": "content", "text": cumulative}
+ yield {
+ "type": "content",
+ "text": _strip_tool_markup(
+ cumulative, final = True
+ ),
+ }
else:
cumulative = reasoning_text
yield {"type": "content", "text": cumulative}
@@ -1713,7 +1925,11 @@ class LlamaCppBackend:
cumulative += ""
in_thinking = False
cumulative += token
- yield {"type": "content", "text": cumulative}
+ cleaned = _strip_tool_markup(cumulative)
+ # Only emit when cleaned text grows (monotonic).
+ if len(cleaned) > len(_last_emitted):
+ _last_emitted = cleaned
+ yield {"type": "content", "text": cleaned}
except json.JSONDecodeError:
logger.debug(
f"Skipping malformed SSE line: {line[:100]}"
diff --git a/studio/backend/core/inference/orchestrator.py b/studio/backend/core/inference/orchestrator.py
index 42de014a8d..6ff7fd2cbf 100644
--- a/studio/backend/core/inference/orchestrator.py
+++ b/studio/backend/core/inference/orchestrator.py
@@ -130,17 +130,17 @@ class InferenceOrchestrator:
)
if resp.status_code == 200:
models = resp.json()
- # Top 8 GGUFs (frontend deduplicates against downloaded,
- # so we fetch extra to always fill 4 slots)
+ # Top 40 GGUFs - frontend pages through them on-demand via
+ # infinite scroll, so we send a deep pool.
gguf_ids = [
m["id"] for m in models if m.get("id", "").upper().endswith("-GGUF")
- ][:8]
- # Top 8 non-GGUF hub models
+ ][:40]
+ # Top 40 non-GGUF hub models
hub_ids = [
m["id"]
for m in models
if not m.get("id", "").upper().endswith("-GGUF")
- ][:8]
+ ][:40]
if gguf_ids:
self._top_gguf_cache = gguf_ids
logger.info("Top GGUF models: %s", gguf_ids)
diff --git a/studio/backend/core/inference/tools.py b/studio/backend/core/inference/tools.py
index ee89dd2c0b..159facabe9 100644
--- a/studio/backend/core/inference/tools.py
+++ b/studio/backend/core/inference/tools.py
@@ -16,12 +16,42 @@ import sys
import tempfile
import threading
+from loggers import get_logger
from unsloth_zoo.rl_environments import check_signal_escape_patterns
+logger = get_logger(__name__)
+
_EXEC_TIMEOUT = 300 # 5 minutes
_MAX_OUTPUT_CHARS = 8000 # truncate long output
_BASH_BLOCKED_WORDS = {"rm", "sudo", "dd", "chmod", "mkfs", "shutdown", "reboot"}
+# Per-session working directories so each chat thread gets its own sandbox.
+# Falls back to a shared ~/studio_sandbox/ for API callers without a session_id.
+_workdirs: dict[str, str] = {}
+
+
+def _get_workdir(session_id: str | None = None) -> str:
+ """Return (and lazily create) a persistent working directory for tool execution."""
+ global _workdirs
+ key = session_id or "_default"
+ if key not in _workdirs or not os.path.isdir(_workdirs[key]):
+ home = os.path.expanduser("~")
+ sandbox_root = os.path.join(home, "studio_sandbox")
+ if session_id:
+ # Sanitize: strip path separators and parent-dir references
+ safe_id = os.path.basename(session_id.replace("..", ""))
+ if not safe_id:
+ safe_id = "_invalid"
+ workdir = os.path.join(sandbox_root, safe_id)
+ # Verify resolved path stays under sandbox root
+ if not os.path.realpath(workdir).startswith(os.path.realpath(sandbox_root)):
+ workdir = os.path.join(sandbox_root, "_invalid")
+ else:
+ workdir = sandbox_root
+ os.makedirs(workdir, exist_ok = True)
+ _workdirs[key] = workdir
+ return _workdirs[key]
+
WEB_SEARCH_TOOL = {
"type": "function",
@@ -80,25 +110,47 @@ TERMINAL_TOOL = {
ALL_TOOLS = [WEB_SEARCH_TOOL, PYTHON_TOOL, TERMINAL_TOOL]
-def execute_tool(name: str, arguments: dict, cancel_event = None) -> str:
- """Execute a tool by name with the given arguments. Returns result as a string."""
+_TIMEOUT_UNSET = object()
+
+
+def execute_tool(
+ name: str,
+ arguments: dict,
+ cancel_event = None,
+ timeout: int | None = _TIMEOUT_UNSET,
+ session_id: str | None = None,
+) -> str:
+ """Execute a tool by name with the given arguments. Returns result as a string.
+
+ ``timeout``: int sets per-call limit in seconds, ``None`` means no limit,
+ unset (default) uses ``_EXEC_TIMEOUT`` (300 s).
+ ``session_id``: optional thread/session ID for per-conversation sandbox isolation.
+ """
+ logger.info(
+ f"execute_tool: name={name}, session_id={session_id}, timeout={timeout}"
+ )
+ effective_timeout = _EXEC_TIMEOUT if timeout is _TIMEOUT_UNSET else timeout
if name == "web_search":
- return _web_search(arguments.get("query", ""))
+ return _web_search(arguments.get("query", ""), timeout = effective_timeout)
if name == "python":
- return _python_exec(arguments.get("code", ""), cancel_event)
+ return _python_exec(
+ arguments.get("code", ""), cancel_event, effective_timeout, session_id
+ )
if name == "terminal":
- return _bash_exec(arguments.get("command", ""), cancel_event)
+ return _bash_exec(
+ arguments.get("command", ""), cancel_event, effective_timeout, session_id
+ )
return f"Unknown tool: {name}"
-def _web_search(query: str, max_results: int = 5) -> str:
+def _web_search(query: str, max_results: int = 5, timeout: int = _EXEC_TIMEOUT) -> str:
"""Search the web using DuckDuckGo and return formatted results."""
if not query.strip():
return "No query provided."
try:
from ddgs import DDGS
- results = DDGS().text(query, max_results = max_results)
+ results = DDGS(timeout = timeout).text(query, max_results = max_results)
if not results:
return "No results found."
parts = []
@@ -147,7 +199,12 @@ def _truncate(text: str, limit: int = _MAX_OUTPUT_CHARS) -> str:
return text
-def _python_exec(code: str, cancel_event = None) -> str:
+def _python_exec(
+ code: str,
+ cancel_event = None,
+ timeout: int = _EXEC_TIMEOUT,
+ session_id: str | None = None,
+) -> str:
"""Execute Python code in a subprocess sandbox."""
if not code or not code.strip():
return "No code provided."
@@ -158,8 +215,11 @@ def _python_exec(code: str, cancel_event = None) -> str:
return error
tmp_path = None
+ workdir = _get_workdir(session_id)
try:
- fd, tmp_path = tempfile.mkstemp(suffix = ".py", prefix = "studio_exec_")
+ fd, tmp_path = tempfile.mkstemp(
+ suffix = ".py", prefix = "studio_exec_", dir = workdir
+ )
with os.fdopen(fd, "w") as f:
f.write(code)
@@ -168,7 +228,7 @@ def _python_exec(code: str, cancel_event = None) -> str:
stdout = subprocess.PIPE,
stderr = subprocess.STDOUT,
text = True,
- cwd = tempfile.gettempdir(),
+ cwd = workdir,
)
# Spawn cancel watcher if we have a cancel event
@@ -179,11 +239,11 @@ def _python_exec(code: str, cancel_event = None) -> str:
watcher.start()
try:
- output, _ = proc.communicate(timeout = _EXEC_TIMEOUT)
+ output, _ = proc.communicate(timeout = timeout)
except subprocess.TimeoutExpired:
proc.kill()
proc.communicate()
- return _truncate("Execution timed out after 5 minutes.")
+ return _truncate(f"Execution timed out after {timeout} seconds.")
if cancel_event is not None and cancel_event.is_set():
return "Execution cancelled."
@@ -203,7 +263,12 @@ def _python_exec(code: str, cancel_event = None) -> str:
pass
-def _bash_exec(command: str, cancel_event = None) -> str:
+def _bash_exec(
+ command: str,
+ cancel_event = None,
+ timeout: int = _EXEC_TIMEOUT,
+ session_id: str | None = None,
+) -> str:
"""Execute a bash command in a subprocess sandbox."""
if not command or not command.strip():
return "No command provided."
@@ -215,35 +280,35 @@ def _bash_exec(command: str, cancel_event = None) -> str:
return f"Blocked command(s) for safety: {', '.join(sorted(blocked))}"
try:
- with tempfile.TemporaryDirectory() as tmpdir:
- proc = subprocess.Popen(
- ["bash", "-c", command],
- stdout = subprocess.PIPE,
- stderr = subprocess.STDOUT,
- text = True,
- cwd = tmpdir,
+ workdir = _get_workdir(session_id)
+ proc = subprocess.Popen(
+ ["bash", "-c", command],
+ stdout = subprocess.PIPE,
+ stderr = subprocess.STDOUT,
+ text = True,
+ cwd = workdir,
+ )
+
+ if cancel_event is not None:
+ watcher = threading.Thread(
+ target = _cancel_watcher, args = (proc, cancel_event), daemon = True
)
+ watcher.start()
- if cancel_event is not None:
- watcher = threading.Thread(
- target = _cancel_watcher, args = (proc, cancel_event), daemon = True
- )
- watcher.start()
+ try:
+ output, _ = proc.communicate(timeout = timeout)
+ except subprocess.TimeoutExpired:
+ proc.kill()
+ proc.communicate()
+ return _truncate(f"Execution timed out after {timeout} seconds.")
- try:
- output, _ = proc.communicate(timeout = _EXEC_TIMEOUT)
- except subprocess.TimeoutExpired:
- proc.kill()
- proc.communicate()
- return _truncate("Execution timed out after 5 minutes.")
+ if cancel_event is not None and cancel_event.is_set():
+ return "Execution cancelled."
- if cancel_event is not None and cancel_event.is_set():
- return "Execution cancelled."
-
- result = output or ""
- if proc.returncode != 0:
- result = f"Exit code {proc.returncode}:\n{result}"
- return _truncate(result) if result.strip() else "(no output)"
+ result = output or ""
+ if proc.returncode != 0:
+ result = f"Exit code {proc.returncode}:\n{result}"
+ return _truncate(result) if result.strip() else "(no output)"
except Exception as e:
return f"Execution error: {e}"
diff --git a/studio/backend/core/inference/worker.py b/studio/backend/core/inference/worker.py
index 0693908178..2eb46f3217 100644
--- a/studio/backend/core/inference/worker.py
+++ b/studio/backend/core/inference/worker.py
@@ -42,59 +42,25 @@ def _activate_transformers_version(model_name: str) -> None:
if backend_path not in sys.path:
sys.path.insert(0, backend_path)
- from utils.transformers_version import needs_transformers_5, _resolve_base_model
+ from utils.transformers_version import (
+ needs_transformers_5,
+ _resolve_base_model,
+ _ensure_venv_t5_exists,
+ _VENV_T5_DIR,
+ )
resolved = _resolve_base_model(model_name)
if needs_transformers_5(resolved):
- venv_t5 = os.path.join(
- os.path.expanduser("~"), ".unsloth", "studio", ".venv_t5"
- )
- if os.path.isdir(venv_t5):
- sys.path.insert(0, venv_t5)
- logger.info("Activated transformers 5.x from %s", venv_t5)
- else:
- # Fallback: pip install at runtime (slower, ~10-15s)
- logger.warning(".venv_t5 not found at %s β installing at runtime", venv_t5)
- import subprocess as sp
-
- os.makedirs(venv_t5, exist_ok = True)
- r1 = sp.run(
- [
- sys.executable,
- "-m",
- "pip",
- "install",
- "--target",
- venv_t5,
- "--no-deps",
- "transformers==5.3.0",
- ],
- stdout = sp.PIPE,
- stderr = sp.STDOUT,
+ if not _ensure_venv_t5_exists():
+ raise RuntimeError(
+ f"Cannot activate transformers 5.x: .venv_t5 missing at {_VENV_T5_DIR}"
)
- r2 = sp.run(
- [
- sys.executable,
- "-m",
- "pip",
- "install",
- "--target",
- venv_t5,
- "--no-deps",
- "huggingface_hub==1.3.0",
- ],
- stdout = sp.PIPE,
- stderr = sp.STDOUT,
- )
- if r1.returncode != 0 or r2.returncode != 0:
- raise RuntimeError(
- f"Failed to install transformers 5.x into {venv_t5}. "
- f"pip returncode: transformers={r1.returncode}, huggingface_hub={r2.returncode}"
- )
- sys.path.insert(0, venv_t5)
+ if _VENV_T5_DIR not in sys.path:
+ sys.path.insert(0, _VENV_T5_DIR)
+ logger.info("Activated transformers 5.x from %s", _VENV_T5_DIR)
# Propagate to child subprocesses (e.g. GGUF converter)
_pp = os.environ.get("PYTHONPATH", "")
- os.environ["PYTHONPATH"] = venv_t5 + (os.pathsep + _pp if _pp else "")
+ os.environ["PYTHONPATH"] = _VENV_T5_DIR + (os.pathsep + _pp if _pp else "")
else:
logger.info("Using default transformers (4.57.x) for %s", model_name)
diff --git a/studio/backend/core/training/trainer.py b/studio/backend/core/training/trainer.py
index 0aa6d0922e..bc252d1fa5 100644
--- a/studio/backend/core/training/trainer.py
+++ b/studio/backend/core/training/trainer.py
@@ -466,6 +466,7 @@ class UnslothTrainer:
is_dataset_image: bool = False,
is_dataset_audio: bool = False,
trust_remote_code: bool = False,
+ full_finetuning: bool = False,
) -> bool:
"""Load model for training (supports both text and vision models)"""
self.load_in_4bit = load_in_4bit # Store for training_meta.json
@@ -612,6 +613,7 @@ class UnslothTrainer:
dtype = None,
auto_model = CsmForConditionalGeneration,
load_in_4bit = False,
+ full_finetuning = full_finetuning,
token = hf_token,
trust_remote_code = trust_remote_code,
)
@@ -626,6 +628,7 @@ class UnslothTrainer:
model_name = model_name,
dtype = None,
load_in_4bit = False,
+ full_finetuning = full_finetuning,
auto_model = WhisperForConditionalGeneration,
whisper_language = "English",
whisper_task = "transcribe",
@@ -646,6 +649,7 @@ class UnslothTrainer:
max_seq_length = max_seq_length,
dtype = None,
load_in_4bit = load_in_4bit,
+ full_finetuning = full_finetuning,
token = hf_token,
trust_remote_code = trust_remote_code,
)
@@ -684,6 +688,7 @@ class UnslothTrainer:
max_seq_length = max_seq_length,
dtype = torch.float32, # Spark-TTS requires float32
load_in_4bit = False,
+ full_finetuning = full_finetuning,
token = hf_token,
trust_remote_code = trust_remote_code,
)
@@ -697,6 +702,7 @@ class UnslothTrainer:
model_name,
max_seq_length = max_seq_length,
load_in_4bit = False,
+ full_finetuning = full_finetuning,
token = hf_token,
trust_remote_code = trust_remote_code,
)
@@ -712,6 +718,7 @@ class UnslothTrainer:
max_seq_length = max_seq_length,
dtype = None,
load_in_4bit = load_in_4bit,
+ full_finetuning = full_finetuning,
token = hf_token,
trust_remote_code = trust_remote_code,
)
@@ -724,6 +731,7 @@ class UnslothTrainer:
max_seq_length = max_seq_length,
dtype = None, # Auto-detect
load_in_4bit = load_in_4bit,
+ full_finetuning = full_finetuning,
token = hf_token,
trust_remote_code = trust_remote_code,
)
@@ -755,6 +763,7 @@ class UnslothTrainer:
max_seq_length = max_seq_length,
dtype = None, # Auto-detect
load_in_4bit = load_in_4bit,
+ full_finetuning = full_finetuning,
token = hf_token,
trust_remote_code = trust_remote_code,
)
@@ -779,13 +788,14 @@ class UnslothTrainer:
self._source_code_retried = True
logger.info(f"\n'could not get source code' β retrying once...\n")
return self.load_model(
- model_name,
- max_seq_length,
- load_in_4bit,
- hf_token,
- is_dataset_image,
- is_dataset_audio,
- trust_remote_code,
+ model_name = model_name,
+ max_seq_length = max_seq_length,
+ load_in_4bit = load_in_4bit,
+ hf_token = hf_token,
+ is_dataset_image = is_dataset_image,
+ is_dataset_audio = is_dataset_audio,
+ trust_remote_code = trust_remote_code,
+ full_finetuning = full_finetuning,
)
error_msg = str(e)
error_lower = error_msg.lower()
diff --git a/studio/backend/core/training/worker.py b/studio/backend/core/training/worker.py
index 57559ded54..ccd805b7ac 100644
--- a/studio/backend/core/training/worker.py
+++ b/studio/backend/core/training/worker.py
@@ -36,59 +36,25 @@ def _activate_transformers_version(model_name: str) -> None:
if backend_path not in sys.path:
sys.path.insert(0, backend_path)
- from utils.transformers_version import needs_transformers_5, _resolve_base_model
+ from utils.transformers_version import (
+ needs_transformers_5,
+ _resolve_base_model,
+ _ensure_venv_t5_exists,
+ _VENV_T5_DIR,
+ )
resolved = _resolve_base_model(model_name)
if needs_transformers_5(resolved):
- venv_t5 = os.path.join(
- os.path.expanduser("~"), ".unsloth", "studio", ".venv_t5"
- )
- if os.path.isdir(venv_t5):
- sys.path.insert(0, venv_t5)
- logger.info("Activated transformers 5.x from %s", venv_t5)
- else:
- # Fallback: pip install at runtime (slower, ~10-15s)
- logger.warning(".venv_t5 not found at %s β installing at runtime", venv_t5)
- import subprocess as sp
-
- os.makedirs(venv_t5, exist_ok = True)
- r1 = sp.run(
- [
- sys.executable,
- "-m",
- "pip",
- "install",
- "--target",
- venv_t5,
- "--no-deps",
- "transformers==5.3.0",
- ],
- stdout = sp.PIPE,
- stderr = sp.STDOUT,
+ if not _ensure_venv_t5_exists():
+ raise RuntimeError(
+ f"Cannot activate transformers 5.x: .venv_t5 missing at {_VENV_T5_DIR}"
)
- r2 = sp.run(
- [
- sys.executable,
- "-m",
- "pip",
- "install",
- "--target",
- venv_t5,
- "--no-deps",
- "huggingface_hub==1.3.0",
- ],
- stdout = sp.PIPE,
- stderr = sp.STDOUT,
- )
- if r1.returncode != 0 or r2.returncode != 0:
- raise RuntimeError(
- f"Failed to install transformers 5.x into {venv_t5}. "
- f"pip returncode: transformers={r1.returncode}, huggingface_hub={r2.returncode}"
- )
- sys.path.insert(0, venv_t5)
+ if _VENV_T5_DIR not in sys.path:
+ sys.path.insert(0, _VENV_T5_DIR)
+ logger.info("Activated transformers 5.x from %s", _VENV_T5_DIR)
# Propagate to child subprocesses (e.g. GGUF converter)
_pp = os.environ.get("PYTHONPATH", "")
- os.environ["PYTHONPATH"] = venv_t5 + (os.pathsep + _pp if _pp else "")
+ os.environ["PYTHONPATH"] = _VENV_T5_DIR + (os.pathsep + _pp if _pp else "")
else:
logger.info("Using default transformers (4.57.x) for %s", model_name)
@@ -444,12 +410,16 @@ def run_training_process(
_tqdm_thread = _th.Thread(target = _monitor_tqdm, daemon = True)
_tqdm_thread.start()
+ training_type = config.get("training_type", "LoRA/QLoRA")
+ use_lora = training_type == "LoRA/QLoRA"
+
# ββ 4c. Load training model (uses VRAM β dataset already formatted) ββ
_send_status(event_queue, "Loading model...")
success = trainer.load_model(
model_name = model_name,
max_seq_length = config["max_seq_length"],
load_in_4bit = config["load_in_4bit"],
+ full_finetuning = not use_lora,
hf_token = hf_token,
is_dataset_image = config.get("is_dataset_image", False),
is_dataset_audio = config.get("is_dataset_audio", False),
@@ -473,8 +443,6 @@ def run_training_process(
return
# ββ 4d. Prepare model (LoRA or full finetuning) ββ
- training_type = config.get("training_type", "LoRA/QLoRA")
- use_lora = training_type == "LoRA/QLoRA"
if use_lora:
_send_status(event_queue, "Configuring LoRA adapters...")
success = trainer.prepare_model_for_training(
diff --git a/studio/backend/models/inference.py b/studio/backend/models/inference.py
index 5d17d2d0be..41a942d217 100644
--- a/studio/backend/models/inference.py
+++ b/studio/backend/models/inference.py
@@ -318,6 +318,24 @@ class ChatCompletionRequest(BaseModel):
None,
description = "[x-unsloth] List of enabled tool names (e.g. ['web_search', 'python', 'terminal']). If None, all tools are enabled.",
)
+ auto_heal_tool_calls: Optional[bool] = Field(
+ True,
+ description = "[x-unsloth] Auto-detect and fix malformed tool calls from model output.",
+ )
+ max_tool_calls_per_message: Optional[int] = Field(
+ 10,
+ ge = 0,
+ description = "[x-unsloth] Maximum number of tool call iterations per message (0 = disabled, 9999 = unlimited).",
+ )
+ tool_call_timeout: Optional[int] = Field(
+ 300,
+ ge = 1,
+ description = "[x-unsloth] Timeout in seconds for each tool call execution (9999 = no limit).",
+ )
+ session_id: Optional[str] = Field(
+ None,
+ description = "[x-unsloth] Session/thread ID for scoping tool execution sandbox.",
+ )
# ββ Streaming response chunks ββββββββββββββββββββββββββββββββββββ
diff --git a/studio/backend/requirements/extras-no-deps.txt b/studio/backend/requirements/extras-no-deps.txt
index 29bd421a0a..4b5aa86b5f 100644
--- a/studio/backend/requirements/extras-no-deps.txt
+++ b/studio/backend/requirements/extras-no-deps.txt
@@ -8,7 +8,7 @@ snac
# TRL and related packages
trl==0.23.1
git+https://github.com/meta-pytorch/OpenEnv.git
-executorch>=1.0.1
+# executorch>=1.0.1 # 41.5 MB - no imports in unsloth/zoo/studio
torch-c-dlpack-ext
sentence_transformers==5.2.0
-transformers==4.57.1
+transformers==4.57.6
diff --git a/studio/backend/requirements/extras.txt b/studio/backend/requirements/extras.txt
index 51ef69cf5d..170b1c9ec8 100644
--- a/studio/backend/requirements/extras.txt
+++ b/studio/backend/requirements/extras.txt
@@ -4,7 +4,7 @@ tomli-w
# ExecuTorch dependencies
ruamel.yaml
-coremltools
+# coremltools # 10.2 MB - Apple CoreML, no imports in unsloth/zoo/studio
expecttest
flatbuffers
hydra-core
@@ -15,7 +15,7 @@ pytest<9.0
pytest-json-report
pytest-rerunfailures==15.1
pytest-xdist
-# Also needed by sentence_transformers
+# Also needed by sentence_transformers (installed with --no-deps in extras-no-deps.txt)
scikit-learn==1.7.1
# Additional extras
@@ -26,8 +26,8 @@ omegaconf
einx
pyloudnorm
openai-whisper
-uroman
-MeCab
+# uroman # 4.0 MB - romanization, no imports found
+# MeCab # 19.9 MB - Japanese tokenizer, no imports found
loguru
flatten_dict
ffmpy
diff --git a/studio/backend/requirements/single-env/constraints.txt b/studio/backend/requirements/single-env/constraints.txt
index 1789bbf713..156f78567e 100644
--- a/studio/backend/requirements/single-env/constraints.txt
+++ b/studio/backend/requirements/single-env/constraints.txt
@@ -1,6 +1,6 @@
# Single-env pins for unsloth + studio + data-designer
# Keep compatible with unsloth transformers bounds.
-transformers==4.57.1
+transformers==4.57.6
trl==0.23.1
huggingface-hub==0.36.2
diff --git a/studio/backend/requirements/studio.txt b/studio/backend/requirements/studio.txt
index da0b4f382b..186ba82fe0 100644
--- a/studio/backend/requirements/studio.txt
+++ b/studio/backend/requirements/studio.txt
@@ -10,7 +10,7 @@ datasets==4.3.0
pyjwt
easydict
addict
-gradio>=4.0.0
+# gradio>=4.0.0 # 148 MB - Studio uses React + FastAPI, not Gradio
huggingface-hub==0.36.2
structlog>=24.1.0
diceware
diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py
index d50f53ba8d..4c98dc6d24 100644
--- a/studio/backend/routes/inference.py
+++ b/studio/backend/routes/inference.py
@@ -1028,7 +1028,7 @@ async def openai_chat_completions(
if use_tools:
from core.inference.tools import ALL_TOOLS
- if payload.enabled_tools:
+ if payload.enabled_tools is not None:
tools_to_use = [
t
for t in ALL_TOOLS
@@ -1050,6 +1050,16 @@ async def openai_chat_completions(
presence_penalty = payload.presence_penalty,
cancel_event = cancel_event,
enable_thinking = payload.enable_thinking,
+ auto_heal_tool_calls = payload.auto_heal_tool_calls
+ if payload.auto_heal_tool_calls is not None
+ else True,
+ max_tool_iterations = payload.max_tool_calls_per_message
+ if payload.max_tool_calls_per_message is not None
+ else 10,
+ tool_call_timeout = payload.tool_call_timeout
+ if payload.tool_call_timeout is not None
+ else 300,
+ session_id = payload.session_id,
)
_tool_sentinel = object()
@@ -1093,6 +1103,10 @@ async def openai_chat_completions(
yield f"data: {status_data}\n\n"
continue
+ if event["type"] in ("tool_start", "tool_end"):
+ yield f"data: {json.dumps(event)}\n\n"
+ continue
+
# "content" type -- cumulative text
cumulative = event.get("text", "")
new_text = cumulative[len(prev_text) :]
diff --git a/studio/backend/utils/transformers_version.py b/studio/backend/utils/transformers_version.py
index 5666b3be35..60b43500c0 100644
--- a/studio/backend/utils/transformers_version.py
+++ b/studio/backend/utils/transformers_version.py
@@ -26,6 +26,7 @@ import json
import structlog
from loggers import get_logger
import os
+import shutil
import subprocess
import sys
from pathlib import Path
@@ -58,7 +59,7 @@ _tokenizer_class_cache: dict[str, bool] = {}
# Versions
TRANSFORMERS_5_VERSION = "5.3.0"
-TRANSFORMERS_DEFAULT_VERSION = "4.57.1"
+TRANSFORMERS_DEFAULT_VERSION = "4.57.6"
# Pre-installed directory for transformers 5.x β created by setup.sh / setup.ps1
_VENV_T5_DIR = str(Path.home() / ".unsloth" / "studio" / ".venv_t5")
@@ -216,15 +217,87 @@ def _purge_modules() -> int:
return len(to_remove)
-def _ensure_venv_t5_exists() -> bool:
- """Ensure .venv_t5/ exists. Install at runtime if missing."""
- if os.path.isdir(_VENV_T5_DIR) and os.listdir(_VENV_T5_DIR):
- return True
+_VENV_T5_PACKAGES = (
+ f"transformers=={TRANSFORMERS_5_VERSION}",
+ "huggingface_hub==1.7.1",
+ "hf_xet==1.4.2",
+ "tiktoken",
+)
- logger.warning(".venv_t5 not found at %s β installing at runtime", _VENV_T5_DIR)
- os.makedirs(_VENV_T5_DIR, exist_ok = True)
- for pkg in (f"transformers=={TRANSFORMERS_5_VERSION}", "huggingface_hub==1.3.0"):
- cmd = [
+
+def _venv_t5_is_valid() -> bool:
+ """Return True if .venv_t5/ has all required packages at the correct versions."""
+ if not os.path.isdir(_VENV_T5_DIR) or not os.listdir(_VENV_T5_DIR):
+ return False
+ # Check that the key package directories exist AND match the required version
+ for pkg_spec in _VENV_T5_PACKAGES:
+ parts = pkg_spec.split("==")
+ pkg_name = parts[0]
+ pkg_version = parts[1] if len(parts) > 1 else None
+ pkg_name_norm = pkg_name.replace("-", "_")
+ # Check directory exists
+ if not any(
+ (Path(_VENV_T5_DIR) / d).is_dir()
+ for d in (pkg_name_norm, pkg_name_norm.replace("_", "-"))
+ ):
+ return False
+ # For unpinned packages, existence is enough
+ if pkg_version is None:
+ continue
+ # Check version via .dist-info metadata
+ dist_info_found = False
+ for di in Path(_VENV_T5_DIR).glob(f"{pkg_name_norm}-*.dist-info"):
+ metadata = di / "METADATA"
+ if not metadata.is_file():
+ continue
+ for line in metadata.read_text(errors = "replace").splitlines():
+ if line.startswith("Version:"):
+ installed_ver = line.split(":", 1)[1].strip()
+ if installed_ver != pkg_version:
+ logger.info(
+ ".venv_t5 has %s==%s but need %s",
+ pkg_name,
+ installed_ver,
+ pkg_version,
+ )
+ return False
+ dist_info_found = True
+ break
+ if dist_info_found:
+ break
+ if not dist_info_found:
+ return False
+ return True
+
+
+def _install_to_venv_t5(pkg: str) -> bool:
+ """Install a single package into .venv_t5/, preferring uv then pip."""
+ # Try uv first (faster) if already on PATH -- do NOT install uv at runtime
+ if shutil.which("uv"):
+ result = subprocess.run(
+ [
+ "uv",
+ "pip",
+ "install",
+ "--python",
+ sys.executable,
+ "--target",
+ _VENV_T5_DIR,
+ "--no-deps",
+ "--upgrade",
+ pkg,
+ ],
+ stdout = subprocess.PIPE,
+ stderr = subprocess.STDOUT,
+ text = True,
+ )
+ if result.returncode == 0:
+ return True
+ logger.warning("uv install of %s failed, falling back to pip", pkg)
+
+ # Fallback to pip
+ result = subprocess.run(
+ [
sys.executable,
"-m",
"pip",
@@ -232,13 +305,31 @@ def _ensure_venv_t5_exists() -> bool:
"--target",
_VENV_T5_DIR,
"--no-deps",
+ "--upgrade",
pkg,
- ]
- result = subprocess.run(
- cmd, stdout = subprocess.PIPE, stderr = subprocess.STDOUT, text = True
- )
- if result.returncode != 0:
- logger.error("pip install failed:\n%s", result.stdout)
+ ],
+ stdout = subprocess.PIPE,
+ stderr = subprocess.STDOUT,
+ text = True,
+ )
+ if result.returncode != 0:
+ logger.error("install failed:\n%s", result.stdout)
+ return False
+ return True
+
+
+def _ensure_venv_t5_exists() -> bool:
+ """Ensure .venv_t5/ exists with all required packages. Install if missing."""
+ if _venv_t5_is_valid():
+ return True
+
+ logger.warning(
+ ".venv_t5 not found or incomplete at %s -- installing at runtime", _VENV_T5_DIR
+ )
+ shutil.rmtree(_VENV_T5_DIR, ignore_errors = True)
+ os.makedirs(_VENV_T5_DIR, exist_ok = True)
+ for pkg in _VENV_T5_PACKAGES:
+ if not _install_to_venv_t5(pkg):
return False
logger.info("Installed transformers 5.x to %s", _VENV_T5_DIR)
return True
diff --git a/studio/frontend/bun.lock b/studio/frontend/bun.lock
index 6ac0c76470..7e3b0ac51e 100644
--- a/studio/frontend/bun.lock
+++ b/studio/frontend/bun.lock
@@ -5,7 +5,7 @@
"": {
"name": "unsloth-theme",
"dependencies": {
- "@assistant-ui/react": "^0.12.17",
+ "@assistant-ui/react": "^0.12.19",
"@assistant-ui/react-markdown": "^0.12.3",
"@assistant-ui/react-streamdown": "^0.1.2",
"@base-ui/react": "^1.2.0",
@@ -43,7 +43,7 @@
"framer-motion": "^11.18.2",
"js-yaml": "^4.1.1",
"katex": "^0.16.28",
- "lucide-react": "^0.575.0",
+ "lucide-react": "^0.577.0",
"mammoth": "^1.11.0",
"motion": "^12.34.0",
"next": "^16.1.6",
@@ -88,17 +88,17 @@
"@antfu/ni": ["@antfu/ni@25.0.0", "", { "dependencies": { "ansis": "^4.0.0", "fzf": "^0.5.2", "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" }, "bin": { "na": "bin/na.mjs", "ni": "bin/ni.mjs", "nr": "bin/nr.mjs", "nci": "bin/nci.mjs", "nlx": "bin/nlx.mjs", "nun": "bin/nun.mjs", "nup": "bin/nup.mjs" } }, "sha512-9q/yCljni37pkMr4sPrI3G4jqdIk074+iukc5aFJl7kmDCCsiJrbZ6zKxnES1Gwg+i9RcDZwvktl23puGslmvA=="],
- "@assistant-ui/core": ["@assistant-ui/core@0.1.5", "", { "dependencies": { "assistant-stream": "^0.3.5", "nanoid": "^5.1.6" }, "peerDependencies": { "@assistant-ui/store": "^0.2.2", "@assistant-ui/tap": "^0.5.2", "@types/react": "*", "assistant-cloud": "^0.1.21", "react": "^18 || ^19", "zustand": "^5.0.11" }, "optionalPeers": ["@types/react", "assistant-cloud", "react", "zustand"] }, "sha512-kLqFbRULZvE+hIwxGz705BW3QYhfwiVaVWoolfTGYkg+4xwah1PGuH0zqjXP5AMADtz+L69Lp+LX0xU9MQZ0DA=="],
+ "@assistant-ui/core": ["@assistant-ui/core@0.1.7", "", { "dependencies": { "assistant-stream": "^0.3.6", "nanoid": "^5.1.6" }, "peerDependencies": { "@assistant-ui/store": "^0.2.3", "@assistant-ui/tap": "^0.5.3", "@types/react": "*", "assistant-cloud": "^0.1.22", "react": "^18 || ^19", "zustand": "^5.0.11" }, "optionalPeers": ["@types/react", "assistant-cloud", "react", "zustand"] }, "sha512-219T42ihVOicbJXZLWgD2CW5Bylg9Nk7geC331X4RfJxTDYlm2zIjViGlGaqfj6URXBp6kMulO2BTUrHGmAvdw=="],
- "@assistant-ui/react": ["@assistant-ui/react@0.12.17", "", { "dependencies": { "@assistant-ui/core": "^0.1.5", "@assistant-ui/store": "^0.2.2", "@assistant-ui/tap": "^0.5.2", "@radix-ui/primitive": "^1.1.3", "@radix-ui/react-compose-refs": "^1.1.2", "@radix-ui/react-context": "^1.1.3", "@radix-ui/react-primitive": "^2.1.4", "@radix-ui/react-use-callback-ref": "^1.1.1", "@radix-ui/react-use-escape-keydown": "^1.1.1", "assistant-cloud": "^0.1.21", "assistant-stream": "^0.3.4", "nanoid": "^5.1.6", "radix-ui": "^1.4.3", "react-textarea-autosize": "^8.5.9", "zod": "^4.3.6", "zustand": "^5.0.11" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^18 || ^19", "react-dom": "^18 || ^19" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t4Z8LatD3LQrtURLaYPG47r4iG7UQgkdoi5YEv+EhzvYiG8I7kAyV4SbnFH6sXPrnleV4IpBHAd8Wc7ynkQtsw=="],
+ "@assistant-ui/react": ["@assistant-ui/react@0.12.19", "", { "dependencies": { "@assistant-ui/core": "^0.1.7", "@assistant-ui/store": "^0.2.3", "@assistant-ui/tap": "^0.5.3", "@radix-ui/primitive": "^1.1.3", "@radix-ui/react-compose-refs": "^1.1.2", "@radix-ui/react-context": "^1.1.3", "@radix-ui/react-primitive": "^2.1.4", "@radix-ui/react-use-callback-ref": "^1.1.1", "@radix-ui/react-use-escape-keydown": "^1.1.1", "assistant-cloud": "^0.1.22", "assistant-stream": "^0.3.6", "nanoid": "^5.1.6", "radix-ui": "^1.4.3", "react-textarea-autosize": "^8.5.9", "zod": "^4.3.6", "zustand": "^5.0.11" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^18 || ^19", "react-dom": "^18 || ^19" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-scAf0o8cwjuHT9Y44EFGXcE2y6BSmpeMvt0NxOn8+Y/HBlNttQMLNvrM0p2AjacXCUufagiafAnWybzBV3nKEQ=="],
"@assistant-ui/react-markdown": ["@assistant-ui/react-markdown@0.12.4", "", { "dependencies": { "@radix-ui/react-primitive": "^2.1.4", "@radix-ui/react-use-callback-ref": "^1.1.1", "classnames": "^2.5.1", "react-markdown": "^10.1.0" }, "peerDependencies": { "@assistant-ui/react": "^0.12.11", "@types/react": "*", "react": "^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-6TD9guiuLJxJoOwSjNHUYAVma2ctDCG9uypUqKHE0OUhDwTDD3NsMvTnQ0n0Lh8nnCEwVglOwKKlSEYpV7SnWA=="],
"@assistant-ui/react-streamdown": ["@assistant-ui/react-streamdown@0.1.3", "", { "dependencies": { "rehype-harden": "^1.1.7", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "streamdown": "^2.1.0" }, "peerDependencies": { "@assistant-ui/react": "^0.12.11", "@streamdown/cjk": "^1.0.0", "@streamdown/code": "^1.0.0", "@streamdown/math": "^1.0.0", "@streamdown/mermaid": "^1.0.0", "@types/react": "*", "react": "^18 || ^19" }, "optionalPeers": ["@streamdown/cjk", "@streamdown/code", "@streamdown/math", "@streamdown/mermaid", "@types/react"] }, "sha512-n1UCjXQ3svmDtJBMJj/vXqz/BqAQBuy7myrXeymz2tD9l+ENQgqu2JY5ir3J19juJTe5lsi/P3+tOJ2C1jc/nw=="],
- "@assistant-ui/store": ["@assistant-ui/store@0.2.2", "", { "dependencies": { "use-effect-event": "^2.0.3" }, "peerDependencies": { "@assistant-ui/tap": "^0.5.2", "@types/react": "*", "react": "^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-JzQseWFp3UmbByBSWQmiGi/bz5jbfru04hIgb2DJBpnnTyns8Zl+8wDPnwiYGF/6SA+IzTg5M0V1wf77rwU0dA=="],
+ "@assistant-ui/store": ["@assistant-ui/store@0.2.3", "", { "dependencies": { "use-effect-event": "^2.0.3" }, "peerDependencies": { "@assistant-ui/tap": "^0.5.3", "@types/react": "*", "react": "^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-daStbgSQiX7+csqK6Cvo7A8p8UZkTCSMxBHxbhJvwrlVbp7BRJWTxq3U3rpTkSGIar23SXIyVRRfXU8VW7pswA=="],
- "@assistant-ui/tap": ["@assistant-ui/tap@0.5.2", "", { "peerDependencies": { "@types/react": "*", "react": "^18 || ^19" }, "optionalPeers": ["@types/react", "react"] }, "sha512-w6gXhr+mF6cPG6ZCnkqV4kkOHzR+Fb+52S4T34PnrH0cs8l2Gqlwo/kB9BcB9fGmjwL7izdwubQ7t2VBhWpz/Q=="],
+ "@assistant-ui/tap": ["@assistant-ui/tap@0.5.3", "", { "peerDependencies": { "@types/react": "*", "react": "^18 || ^19" }, "optionalPeers": ["@types/react", "react"] }, "sha512-wy06ksqF2LfFxe4JXy31Ns89N/be1Dy3c+mG363cFHFp3CbLkRu8CrCN2SQSgCkXt628E+D8QyzqdBcl9kD4NQ=="],
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
@@ -846,7 +846,7 @@
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
- "assistant-cloud": ["assistant-cloud@0.1.21", "", { "dependencies": { "assistant-stream": "^0.3.4" } }, "sha512-KZ9ZsF1i1zMhozvD4m8TsmTdtufqULMaqgOoSLRyVtnhwvxkDufL87tSjv7epddZ4kbebe31biWSg7KIlgzvQA=="],
+ "assistant-cloud": ["assistant-cloud@0.1.22", "", { "dependencies": { "assistant-stream": "^0.3.6" } }, "sha512-AEE9shV+oFrGDv/MRTRERctNKpIYS0n34UpAQXXICiOkSWD6QZnS1ljLqruFko7fJoT5CIWq8dNeJWdzQLTBLg=="],
"assistant-stream": ["assistant-stream@0.3.3", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "nanoid": "^5.1.6", "secure-json-parse": "^4.1.0" } }, "sha512-Ne/uTseMIiZx740dTbr/SWxONM8nYj4Z5BRmUfqQN+TNgtOCgWOlC/oTUQ+A7LIUHtmGbcoyZwDf8yd2RASnDA=="],
@@ -1450,7 +1450,7 @@
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
- "lucide-react": ["lucide-react@0.575.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-VuXgKZrk0uiDlWjGGXmKV6MSk9Yy4l10qgVvzGn2AWBx1Ylt0iBexKOAoA6I7JO3m+M9oeovJd3yYENfkUbOeg=="],
+ "lucide-react": ["lucide-react@0.577.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
@@ -2082,9 +2082,9 @@
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
- "@assistant-ui/core/assistant-stream": ["assistant-stream@0.3.5", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "nanoid": "^5.1.6", "secure-json-parse": "^4.1.0" } }, "sha512-OGxVClfpEOoSsJDraPoe+GYTwh9TJX1wxK3hT5Qs7gIOyD/MZbqwyWwabRO2KTnNU4w7usvmC/vneUzxSk4bBg=="],
+ "@assistant-ui/core/assistant-stream": ["assistant-stream@0.3.6", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "nanoid": "^5.1.6", "secure-json-parse": "^4.1.0" } }, "sha512-NdtSRrQfWCDA/aqQ1xhobf/xnhuMZkhFAw9xzAt5iAoL3ouxVXOowSRN87OL4MYBQEvqtcjw9/CE6YcsXoBtuw=="],
- "@assistant-ui/react/assistant-stream": ["assistant-stream@0.3.5", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "nanoid": "^5.1.6", "secure-json-parse": "^4.1.0" } }, "sha512-OGxVClfpEOoSsJDraPoe+GYTwh9TJX1wxK3hT5Qs7gIOyD/MZbqwyWwabRO2KTnNU4w7usvmC/vneUzxSk4bBg=="],
+ "@assistant-ui/react/assistant-stream": ["assistant-stream@0.3.6", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "nanoid": "^5.1.6", "secure-json-parse": "^4.1.0" } }, "sha512-NdtSRrQfWCDA/aqQ1xhobf/xnhuMZkhFAw9xzAt5iAoL3ouxVXOowSRN87OL4MYBQEvqtcjw9/CE6YcsXoBtuw=="],
"@assistant-ui/react/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
@@ -2282,7 +2282,7 @@
"ajv-formats/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
- "assistant-cloud/assistant-stream": ["assistant-stream@0.3.5", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "nanoid": "^5.1.6", "secure-json-parse": "^4.1.0" } }, "sha512-OGxVClfpEOoSsJDraPoe+GYTwh9TJX1wxK3hT5Qs7gIOyD/MZbqwyWwabRO2KTnNU4w7usvmC/vneUzxSk4bBg=="],
+ "assistant-cloud/assistant-stream": ["assistant-stream@0.3.6", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "nanoid": "^5.1.6", "secure-json-parse": "^4.1.0" } }, "sha512-NdtSRrQfWCDA/aqQ1xhobf/xnhuMZkhFAw9xzAt5iAoL3ouxVXOowSRN87OL4MYBQEvqtcjw9/CE6YcsXoBtuw=="],
"chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
diff --git a/studio/frontend/package.json b/studio/frontend/package.json
index b3acb6468f..4b40759d62 100644
--- a/studio/frontend/package.json
+++ b/studio/frontend/package.json
@@ -13,7 +13,7 @@
"biome:fix": "biome check . --write"
},
"dependencies": {
- "@assistant-ui/react": "^0.12.17",
+ "@assistant-ui/react": "^0.12.19",
"@assistant-ui/react-markdown": "^0.12.3",
"@assistant-ui/react-streamdown": "^0.1.2",
"@base-ui/react": "^1.2.0",
@@ -51,7 +51,7 @@
"framer-motion": "^11.18.2",
"js-yaml": "^4.1.1",
"katex": "^0.16.28",
- "lucide-react": "^0.575.0",
+ "lucide-react": "^0.577.0",
"mammoth": "^1.11.0",
"motion": "^12.34.0",
"next": "^16.1.6",
diff --git a/studio/frontend/src/components/assistant-ui/badge.tsx b/studio/frontend/src/components/assistant-ui/badge.tsx
new file mode 100644
index 0000000000..7b189b349c
--- /dev/null
+++ b/studio/frontend/src/components/assistant-ui/badge.tsx
@@ -0,0 +1,67 @@
+"use client";
+
+import type { ComponentProps } from "react";
+import { Slot } from "radix-ui";
+import { cva, type VariantProps } from "class-variance-authority";
+import { cn } from "@/lib/utils";
+
+const badgeVariants = cva(
+ "inline-flex items-center justify-center gap-1 rounded-md font-medium text-xs transition-colors [&_svg]:size-3 [&_svg]:shrink-0",
+ {
+ variants: {
+ variant: {
+ outline:
+ "border border-input bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground",
+ secondary:
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ muted:
+ "bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground",
+ ghost:
+ "bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground",
+ info: "bg-blue-100 text-blue-700 hover:bg-blue-100/80 dark:bg-blue-900/50 dark:text-blue-300",
+ warning:
+ "bg-amber-100 text-amber-700 hover:bg-amber-100/80 dark:bg-amber-900/50 dark:text-amber-300",
+ success:
+ "bg-emerald-100 text-emerald-700 hover:bg-emerald-100/80 dark:bg-emerald-900/50 dark:text-emerald-300",
+ destructive:
+ "bg-red-100 text-red-700 hover:bg-red-100/80 dark:bg-red-900/50 dark:text-red-300",
+ },
+ size: {
+ sm: "px-1.5 py-0.5",
+ default: "px-2 py-1",
+ lg: "px-2.5 py-1.5 text-sm",
+ },
+ },
+ defaultVariants: {
+ variant: "outline",
+ size: "default",
+ },
+ },
+);
+
+export type BadgeProps = ComponentProps<"span"> &
+ VariantProps & {
+ asChild?: boolean;
+ };
+
+function Badge({
+ className,
+ variant,
+ size,
+ asChild = false,
+ ...props
+}: BadgeProps) {
+ const Comp = asChild ? Slot.Root : "span";
+
+ return (
+
+ );
+}
+
+export { Badge, badgeVariants };
diff --git a/studio/frontend/src/components/assistant-ui/markdown-text.tsx b/studio/frontend/src/components/assistant-ui/markdown-text.tsx
index e41ec10dfe..0bbbe94fdc 100644
--- a/studio/frontend/src/components/assistant-ui/markdown-text.tsx
+++ b/studio/frontend/src/components/assistant-ui/markdown-text.tsx
@@ -10,7 +10,7 @@ import { HugeiconsIcon } from "@hugeicons/react";
import { code } from "@streamdown/code";
import { math } from "@streamdown/math";
import { mermaid } from "@streamdown/mermaid";
-import { DownloadIcon } from "lucide-react";
+import { DownloadIcon, Maximize2Icon, Minimize2Icon } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Block, type BlockProps, Streamdown } from "streamdown";
import "katex/dist/katex.min.css";
@@ -84,6 +84,11 @@ function isSvgFence(codeFence: CodeFence): boolean {
return false;
}
+function isHtmlFence(codeFence: CodeFence): boolean {
+ const lang = codeFence.language?.toLowerCase() ?? "";
+ return lang === "html" && !codeFence.source.trimStart().startsWith("