From 655b0cbcee1d68b22629e3ceeab7f84c7d685af8 Mon Sep 17 00:00:00 2001 From: Michael Han <107991372+shimmyshimmer@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:57:01 -0700 Subject: [PATCH 01/56] Studio: default Hub Discover scope to all models (#6593) - Discover defaults to the whole Hub instead of the unsloth org; an explicit Unsloth choice is still remembered - Discover models placeholder reads Search all models to match - Give the Unsloth/All scope pill a min width so it stays readable --- .../frontend/src/features/hub/catalog/models-toolbar.tsx | 2 +- .../src/features/hub/catalog/owner-scope-toggle.tsx | 4 ++-- studio/frontend/src/features/hub/hub-page.tsx | 9 +++++---- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/studio/frontend/src/features/hub/catalog/models-toolbar.tsx b/studio/frontend/src/features/hub/catalog/models-toolbar.tsx index c8adfb54f5..48f7fcffaa 100644 --- a/studio/frontend/src/features/hub/catalog/models-toolbar.tsx +++ b/studio/frontend/src/features/hub/catalog/models-toolbar.tsx @@ -252,7 +252,7 @@ export const ModelsToolbar = memo(function ModelsToolbar({ ? `Search on-device ${isDataset ? "datasets" : "models"}` : isDataset ? "Search datasets" - : "Search models" + : "Search all models" } className={cn( "field-soft h-9 rounded-full !border-0 pl-10 text-[13px] placeholder:text-muted-foreground/80 focus-visible:!ring-0", diff --git a/studio/frontend/src/features/hub/catalog/owner-scope-toggle.tsx b/studio/frontend/src/features/hub/catalog/owner-scope-toggle.tsx index fed8568f99..5f36f5d031 100644 --- a/studio/frontend/src/features/hub/catalog/owner-scope-toggle.tsx +++ b/studio/frontend/src/features/hub/catalog/owner-scope-toggle.tsx @@ -28,8 +28,8 @@ export function OwnerScopeToggle({ onValueChange={onChange} ariaLabel="Publisher scope" align="end" - // Extra gap so the chevron sits a touch further from the label. - className="h-8 gap-1.5 text-[11.5px]" + // Extra gap before the chevron; min-width keeps the pill readable. + className="h-8 min-w-[96px] gap-1.5 text-[11.5px]" /> ); } diff --git a/studio/frontend/src/features/hub/hub-page.tsx b/studio/frontend/src/features/hub/hub-page.tsx index b3c9dd0dbe..e379d7f2ee 100644 --- a/studio/frontend/src/features/hub/hub-page.tsx +++ b/studio/frontend/src/features/hub/hub-page.tsx @@ -90,18 +90,19 @@ const ALL_MODELS_VIEW_STORAGE_KEY = "unsloth.hub.allModelsView"; const INVENTORY_SORT_STORAGE_KEY = "unsloth.hub.inventorySort"; const OWNER_SCOPE_STORAGE_KEY = "unsloth.hub.ownerScope"; -/** Discover browsing scope: only the unsloth org (default) or the whole Hub. */ +/** Discover browsing scope: the whole Hub (default) or only the unsloth org. */ export type OwnerScope = "unsloth" | "all"; function readOwnerScopePreference(): OwnerScope { if (typeof window === "undefined") { - return "unsloth"; + return "all"; } try { const value = window.localStorage.getItem(OWNER_SCOPE_STORAGE_KEY); - return value === "all" ? "all" : "unsloth"; + // Default to the whole Hub; only honor an explicit "unsloth" preference. + return value === "unsloth" ? "unsloth" : "all"; } catch { - return "unsloth"; + return "all"; } } From 45c01c09bc56767a4a77fe055ec9fff105586125 Mon Sep 17 00:00:00 2001 From: Michael Han <107991372+shimmyshimmer@users.noreply.github.com> Date: Mon, 22 Jun 2026 22:11:45 -0700 Subject: [PATCH 02/56] Studio: model picker search placeholder, Search Hub tooltip, list polish (#6592) Polish for the in-chat model picker popover and its guided-tour step. - Search box placeholder reads Search Unsloth models, matching the Unsloth-only listing. - Search Hub button shows a Search all models tooltip on hover. - Floating Eject pill moves 1px lower so it sits closer to the bottom edge. - Results list max height trimmed by 1px (21rem to 335px) from the bottom only. - Chat guided tour Two tabs step updated to describe Unsloth-scoped search plus Search Hub for all of Hugging Face. --- .../assistant-ui/model-selector/pickers.tsx | 29 +++++++++++-------- .../frontend/src/features/chat/tour/steps.tsx | 7 +++-- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx index 46b6fe2e63..90c1c04106 100644 --- a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx +++ b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx @@ -2336,7 +2336,7 @@ export function HubModelPicker({ setQuery(event.target.value)} - placeholder="Search models" + placeholder="Search Unsloth models" data-model-picker-search-input={true} className="field-soft h-9 border-0 pl-8 pr-8" /> @@ -2345,15 +2345,20 @@ export function HubModelPicker({ )} {onBrowseHub ? ( - + + + + + Search all models + ) : null} @@ -2386,7 +2391,7 @@ export function HubModelPicker({ // Height tracks the content up to the cap, so short lists do not // leave white space. scroll-py + symmetric px keep the focus ring off // the overflow clip edges during keyboard nav. - "model-list-scroll max-h-[21rem] overflow-y-auto scroll-py-1.5 px-0.5 mr-1", + "model-list-scroll max-h-[335px] overflow-y-auto scroll-py-1.5 px-0.5 mr-1", listScrolled && "is-scrolled", listMoreBelow && "is-bottom-faded", )} @@ -3362,7 +3367,7 @@ export function HubModelPicker({ {/* Floating eject pill: overlaid on the list bottom, outside the scroll so the edge fade never touches it. Only the pill catches clicks. */} {onEject ? ( -
+
diff --git a/studio/frontend/src/features/chat/chat-page.tsx b/studio/frontend/src/features/chat/chat-page.tsx index ac0c4de75c..7d4292669d 100644 --- a/studio/frontend/src/features/chat/chat-page.tsx +++ b/studio/frontend/src/features/chat/chat-page.tsx @@ -2506,7 +2506,6 @@ export function ChatPage({ onClick={() => setSettingsOpen(true)} className="flex h-[34px] w-[34px] translate-x-[2px] cursor-pointer items-center justify-center rounded-full text-nav-fg transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" aria-label="Open run settings" - data-tour="chat-settings" > Run settings Chat inference settings -
{settingsContent}
+
+ {settingsContent} +
); @@ -1783,6 +1785,7 @@ export function ChatSettingsPanel({ return (
+ + + diff --git a/studio/backend/main.py b/studio/backend/main.py index a56bd46c4b..a8e81b68e6 100644 --- a/studio/backend/main.py +++ b/studio/backend/main.py @@ -282,6 +282,7 @@ from routes import ( training_router, ) from routes.llama import router as llama_router +from routes.preview import router as preview_router from hub.routes import ( inventory_router as hub_inventory_router, datasets_router as hub_datasets_router, @@ -672,6 +673,7 @@ from utils.upload_limits import ( # noqa: E402 _BODY_PROTECTED_PREFIXES = ( "/v1/chat/completions", "/v1/completions", + "/p/", "/api/inference", "/api/data-recipe", "/api/datasets", @@ -885,6 +887,7 @@ app.include_router(inference_studio_router, prefix = "/api/inference", tags = [" # OpenAI-compatible: mount the inference router at /v1 for external tools. app.include_router(inference_router, prefix = "/v1", tags = ["openai-compat"]) +app.include_router(preview_router, prefix = "/p", tags = ["preview"]) app.include_router(providers_router, prefix = "/api/providers", tags = ["providers"]) app.include_router(settings_router, prefix = "/api/settings", tags = ["settings"]) app.include_router(mcp_servers_router, prefix = "/api/mcp/servers", tags = ["mcp"]) diff --git a/studio/backend/models/training.py b/studio/backend/models/training.py index 670d5f911d..8b6fb36471 100644 --- a/studio/backend/models/training.py +++ b/studio/backend/models/training.py @@ -601,6 +601,8 @@ class TrainingRunSummary(BaseModel): loss_sparkline: Optional[List[float]] = None can_resume: bool = False resumed_later: bool = False + has_preview_model: bool = False + preview_ref: Optional[str] = None class TrainingRunUpdateRequest(BaseModel): diff --git a/studio/backend/routes/preview.py b/studio/backend/routes/preview.py new file mode 100644 index 0000000000..d5247a2bcf --- /dev/null +++ b/studio/backend/routes/preview.py @@ -0,0 +1,196 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""Per-checkpoint preview endpoints: /p/{run}[/{checkpoint}]/v1/...""" + +from __future__ import annotations + +import asyncio +import html +from pathlib import Path +from urllib.parse import quote + +from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse +from loggers import get_logger + +from auth.authentication import get_current_subject +from auth.storage import DEFAULT_ADMIN_USERNAME +from models.inference import ChatCompletionRequest, LoadRequest +from routes.inference import load_model, openai_chat_completions +from state.tool_policy import tools_force_disabled +from utils.models.checkpoints import list_preview_targets, resolve_preview_checkpoint + +logger = get_logger(__name__) + +router = APIRouter() + +# Public (no key); resolve_preview_checkpoint pins `run` under outputs_root. +# One model loads at a time, so serialize load+generate across previews. +_preview_lock = asyncio.Lock() + + +def _resolve_or_4xx(run: str, checkpoint: str | None): + try: + return resolve_preview_checkpoint(run, checkpoint) + except ValueError as exc: + # Detail can carry the absolute install path on a symlink escape; log it, + # return a generic message on this public route. + logger.warning("preview path rejected: %s", exc) + raise HTTPException(status_code = 400, detail = "Invalid run or checkpoint") + except FileNotFoundError as exc: + raise HTTPException(status_code = 404, detail = str(exc)) + + +def _sanitize_preview_payload( + payload: ChatCompletionRequest, is_lora: bool +) -> ChatCompletionRequest: + # Public surface: strip tools/MCP + provider routing (no host code / open proxy). + # Normalize use_adapter (never trust the caller): pin True for LoRA, None for + # merged. _apply_adapter_state mutates the shared model without restoring, so an + # unpinned `false` would persist to later visitors who omit the field. + return payload.model_copy( + update = { + "tools": None, + "enable_tools": False, + "enabled_tools": None, + "mcp_enabled": False, + "bypass_permissions": False, + "confirm_tool_calls": False, + "session_id": None, + "rag_scope": None, + "openai_code_exec_container_id": None, + "anthropic_code_exec_container_id": None, + "provider_id": None, + "provider_type": None, + "external_model": None, + "encrypted_api_key": None, + "provider_base_url": None, + "use_adapter": True if is_lora else None, + } + ) + + +async def _unlock_after(body_iterator): + # Hold the lock until the stream drains so another checkpoint can't swap mid-stream. + try: + async for chunk in body_iterator: + yield chunk + finally: + _preview_lock.release() + + +async def _serve_chat( + run: str, checkpoint: str | None, payload: ChatCompletionRequest, request: Request +): + path = _resolve_or_4xx(run, checkpoint) + is_lora = (path / "adapter_config.json").exists() + payload = _sanitize_preview_payload(payload, is_lora) + await _preview_lock.acquire() + keep_locked = False + try: + await load_model(LoadRequest(model_path = str(path)), request, DEFAULT_ADMIN_USERNAME) + # Beats a process-wide `--enable-tools` (enable_tools=False alone wouldn't). + with tools_force_disabled(): + response = await openai_chat_completions(payload, request, DEFAULT_ADMIN_USERNAME) + if isinstance(response, StreamingResponse): + response.body_iterator = _unlock_after(response.body_iterator) + keep_locked = True + return response + finally: + if not keep_locked: + _preview_lock.release() + + +@router.get("") +async def list_previews(request: Request, current_subject: str = Depends(get_current_subject)): + base = str(request.base_url) + previews = [] + for target in list_preview_targets(): + ref = quote(target["ref"], safe = "/") + previews.append({**target, "url": f"{base}p/{ref}/v1"}) + return {"object": "list", "data": previews} + + +@router.post("/{run}/v1/chat/completions") +async def preview_chat_latest(run: str, payload: ChatCompletionRequest, request: Request): + return await _serve_chat(run, None, payload, request) + + +@router.post("/{run}/{checkpoint}/v1/chat/completions") +async def preview_chat_checkpoint( + run: str, checkpoint: str, payload: ChatCompletionRequest, request: Request +): + return await _serve_chat(run, checkpoint, payload, request) + + +def _models_response(run: str, checkpoint: str | None): + path = _resolve_or_4xx(run, checkpoint) + model_id = run if not checkpoint else f"{run}/{checkpoint}" + return { + "object": "list", + "data": [ + { + "id": model_id, + "object": "model", + "created": int(path.stat().st_mtime), + "owned_by": "unsloth-studio", + } + ], + } + + +@router.get("/{run}/v1/models") +async def preview_models_latest(run: str): + return _models_response(run, None) + + +@router.get("/{run}/{checkpoint}/v1/models") +async def preview_models_checkpoint(run: str, checkpoint: str): + return _models_response(run, checkpoint) + + +# Serve logo/fonts here too: the SPA static mount is absent in --api-only (Tauri). +_FRONTEND_DIST = (Path(__file__).resolve().parents[2] / "frontend" / "dist").resolve() +_PREVIEW_ASSET_MEDIA_TYPES = { + ".png": "image/png", + ".woff": "font/woff", + ".woff2": "font/woff2", +} + + +@router.get("/_assets/{asset_path:path}") +async def preview_asset(asset_path: str): + target = (_FRONTEND_DIST / asset_path).resolve() + media_type = _PREVIEW_ASSET_MEDIA_TYPES.get(target.suffix.lower()) + if media_type is None or not target.is_relative_to(_FRONTEND_DIST) or not target.is_file(): + raise HTTPException(status_code = 404, detail = "Not found") + return FileResponse(target, media_type = media_type) + + +# Self-contained public page; only the title is interpolated. +_PREVIEW_PAGE_HTML = ( + Path(__file__).resolve().parent.parent / "assets" / "preview_page.html" +).read_text(encoding = "utf-8") + +_PREVIEW_PAGE_CSP = ( + "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; " + "img-src 'self'; font-src 'self'; connect-src 'self'; base-uri 'none'" +) + + +def _preview_page(run: str, checkpoint: str | None) -> HTMLResponse: + _resolve_or_4xx(run, checkpoint) + title = run if not checkpoint else f"{run}/{checkpoint}" + page = _PREVIEW_PAGE_HTML.replace("__TITLE__", html.escape(title)) + return HTMLResponse(page, headers = {"Content-Security-Policy": _PREVIEW_PAGE_CSP}) + + +@router.get("/{run}", response_class = HTMLResponse) +async def preview_page_latest(run: str): + return _preview_page(run, None) + + +@router.get("/{run}/{checkpoint}", response_class = HTMLResponse) +async def preview_page_checkpoint(run: str, checkpoint: str): + return _preview_page(run, checkpoint) diff --git a/studio/backend/routes/training_history.py b/studio/backend/routes/training_history.py index 1560c72767..a64d2a938e 100644 --- a/studio/backend/routes/training_history.py +++ b/studio/backend/routes/training_history.py @@ -27,6 +27,7 @@ from storage.studio_db import ( list_runs, update_run_display_name, ) +from utils.models.checkpoints import has_preview_model, preview_ref logger = get_logger(__name__) @@ -42,7 +43,17 @@ async def list_training_runs( """List training runs, newest first.""" result = list_runs(limit = limit, offset = offset) return TrainingRunListResponse( - runs = [TrainingRunSummary(**{**r, "can_resume": can_resume_run(r)}) for r in result["runs"]], + runs = [ + TrainingRunSummary( + **{ + **r, + "can_resume": can_resume_run(r), + "has_preview_model": has_preview_model(r.get("output_dir")), + "preview_ref": preview_ref(r.get("output_dir")), + } + ) + for r in result["runs"] + ], total = result["total"], ) @@ -67,6 +78,8 @@ async def get_training_run_detail(run_id: str, current_subject: str = Depends(ge **{ **{k: v for k, v in run.items() if k != "config_json"}, "can_resume": can_resume_run(run), + "has_preview_model": has_preview_model(run.get("output_dir")), + "preview_ref": preview_ref(run.get("output_dir")), } ), config = config, @@ -98,6 +111,8 @@ async def update_training_run( **{ **{k: v for k, v in refreshed.items() if k != "config_json"}, "can_resume": can_resume_run(refreshed), + "has_preview_model": has_preview_model(refreshed.get("output_dir")), + "preview_ref": preview_ref(refreshed.get("output_dir")), } ) diff --git a/studio/backend/run.py b/studio/backend/run.py index 481fd623f3..9cb7868949 100644 --- a/studio/backend/run.py +++ b/studio/backend/run.py @@ -1095,7 +1095,10 @@ def run_server( app.state.server_port = port if port and port > 0 else None # Direct (non-tunnel) base for the API panel; resolve 0.0.0.0 to the LAN IP. if port and port > 0: - _direct_host = _resolve_external_ip() if host == "0.0.0.0" else host + _direct_host = _resolve_external_ip() if host in ("0.0.0.0", "::") else host + # Bracket IPv6 literals so the URL is valid (http://[2405:...]:port). + if ":" in _direct_host and not _direct_host.startswith("["): + _direct_host = f"[{_direct_host}]" app.state.server_url = f"http://{_direct_host}:{port}" else: app.state.server_url = None diff --git a/studio/backend/state/tool_policy.py b/studio/backend/state/tool_policy.py index 9b0fc7d6cb..e0792321f9 100644 --- a/studio/backend/state/tool_policy.py +++ b/studio/backend/state/tool_policy.py @@ -10,15 +10,34 @@ Set by `unsloth run` at startup; consulted by the inference route gates. False -> CLI forced tools off for every request. """ -from typing import Optional +import contextvars +from contextlib import contextmanager +from typing import Iterator, Optional _tool_policy: Optional[bool] = None +# Per-request hard-off so public surfaces refuse tools even under a CLI `--enable-tools`. +_force_disabled: contextvars.ContextVar[bool] = contextvars.ContextVar( + "tool_policy_force_disabled", default = False +) + def get_tool_policy() -> Optional[bool]: + if _force_disabled.get(): + return False return _tool_policy +@contextmanager +def tools_force_disabled() -> Iterator[None]: + """Hard-disable server-side tools for the current async context.""" + token = _force_disabled.set(True) + try: + yield + finally: + _force_disabled.reset(token) + + def set_tool_policy(value: Optional[bool]) -> None: if value is not None and not isinstance(value, bool): raise TypeError(f"tool_policy must be Optional[bool], got {type(value).__name__}") diff --git a/studio/backend/tests/test_preview.py b/studio/backend/tests/test_preview.py new file mode 100644 index 0000000000..e131f99951 --- /dev/null +++ b/studio/backend/tests/test_preview.py @@ -0,0 +1,134 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import json +from pathlib import Path +import sys +import types as _types + +import pytest + + +_BACKEND_DIR = str(Path(__file__).resolve().parent.parent) +if _BACKEND_DIR not in sys.path: + sys.path.insert(0, _BACKEND_DIR) + +_loggers_stub = _types.ModuleType("loggers") +_loggers_stub.get_logger = lambda name: __import__("logging").getLogger(name) +sys.modules.setdefault("loggers", _loggers_stub) + +from utils.models.checkpoints import ( + list_preview_targets, + preview_ref, + resolve_preview_checkpoint, +) + + +def _make_run(outputs: Path) -> tuple[Path, Path]: + run = outputs / "unsloth_SmolLM-135M_1775412608" + run.mkdir(parents = True) + (run / "adapter_config.json").write_text( + json.dumps({"base_model_name_or_path": "HuggingFaceTB/SmolLM-135M"}) + ) + ckpt = run / "checkpoint-60" + ckpt.mkdir() + (ckpt / "adapter_config.json").write_text( + json.dumps({"base_model_name_or_path": "HuggingFaceTB/SmolLM-135M"}) + ) + return run, ckpt + + +def _point_outputs_root_at(monkeypatch, outputs: Path) -> None: + from utils.paths import storage_roots as _sr + from utils.models import checkpoints as _ckpt + + monkeypatch.setattr(_sr, "outputs_root", lambda: outputs) + # checkpoints imported outputs_root by name; patch that alias too (preview_ref uses it). + monkeypatch.setattr(_ckpt, "outputs_root", lambda: outputs) + + +def test_resolve_main_adapter_and_checkpoint(tmp_path: Path, monkeypatch): + outputs = tmp_path / "outputs" + run, ckpt = _make_run(outputs) + _point_outputs_root_at(monkeypatch, outputs) + + assert resolve_preview_checkpoint(run.name) == run + assert resolve_preview_checkpoint(run.name, "checkpoint-60") == ckpt + + +def test_resolve_missing_raises_not_found(tmp_path: Path, monkeypatch): + outputs = tmp_path / "outputs" + _make_run(outputs) + _point_outputs_root_at(monkeypatch, outputs) + + with pytest.raises(FileNotFoundError): + resolve_preview_checkpoint("does-not-exist") + (outputs / "empty").mkdir() + with pytest.raises(FileNotFoundError): + resolve_preview_checkpoint("empty") + + +def test_resolve_rejects_traversal(tmp_path: Path, monkeypatch): + outputs = tmp_path / "outputs" + _make_run(outputs) + _point_outputs_root_at(monkeypatch, outputs) + + with pytest.raises(ValueError): + resolve_preview_checkpoint("..", "etc") + + +def test_list_preview_targets_flattens_with_latest_flag(tmp_path: Path, monkeypatch): + outputs = tmp_path / "outputs" + run, _ = _make_run(outputs) + _point_outputs_root_at(monkeypatch, outputs) + + targets = list_preview_targets(str(outputs)) + by_ref = {t["ref"]: t for t in targets} + + assert by_ref[run.name]["is_latest"] is True + assert by_ref[run.name]["checkpoint"] is None + assert by_ref[f"{run.name}/checkpoint-60"]["is_latest"] is False + assert by_ref[f"{run.name}/checkpoint-60"]["checkpoint"] == "checkpoint-60" + assert all(t["base_model"] == "HuggingFaceTB/SmolLM-135M" for t in targets) + + +def test_preview_ref_flat_run_is_basename(tmp_path: Path, monkeypatch): + outputs = tmp_path / "outputs" + run, _ = _make_run(outputs) + _point_outputs_root_at(monkeypatch, outputs) + + assert preview_ref(str(run)) == run.name + + +def test_preview_ref_preserves_one_level_nesting(tmp_path: Path, monkeypatch): + outputs = tmp_path / "outputs" + _point_outputs_root_at(monkeypatch, outputs) + nested = outputs / "experiments" / "run1" + nested.mkdir(parents = True) + (nested / "adapter_config.json").write_text("{}") + + # /p route supports run/checkpoint, so a single level of nesting survives. + assert preview_ref(str(nested)) == "experiments/run1" + + +def test_preview_ref_none_for_unpreviewable_or_too_deep(tmp_path: Path, monkeypatch): + outputs = tmp_path / "outputs" + _point_outputs_root_at(monkeypatch, outputs) + + # Missing / no model artifact -> not previewable. + assert preview_ref(None) is None + empty = outputs / "empty" + empty.mkdir(parents = True) + assert preview_ref(str(empty)) is None + + # Too deep for the two-segment /p route -> no dead link. + deep = outputs / "a" / "b" / "run" + deep.mkdir(parents = True) + (deep / "adapter_config.json").write_text("{}") + assert preview_ref(str(deep)) is None + + # Outside outputs_root -> None. + outside = tmp_path / "elsewhere" + outside.mkdir() + (outside / "adapter_config.json").write_text("{}") + assert preview_ref(str(outside)) is None diff --git a/studio/backend/tests/test_preview_routes.py b/studio/backend/tests/test_preview_routes.py new file mode 100644 index 0000000000..d6edd4ef4d --- /dev/null +++ b/studio/backend/tests/test_preview_routes.py @@ -0,0 +1,293 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""Security smoke for the public /p preview routes. + +Exercises the route layer with a real ``preview_router`` while stubbing the +expensive model calls (``load_model`` / ``openai_chat_completions``). Covers the +public-surface guarantees: path-traversal rejection, request sanitization +(tools / provider routing / use_adapter), asset-path containment, the page CSP +header + HTML escaping, and that the preview lock is held until a streaming +response is fully drained. +""" + +import asyncio +import json +from pathlib import Path +import sys +import types as _types + +import pytest + + +_BACKEND_DIR = str(Path(__file__).resolve().parent.parent) +if _BACKEND_DIR not in sys.path: + sys.path.insert(0, _BACKEND_DIR) + +# Mirror test_preview.py: the real `loggers` package pulls in heavy handlers. +_loggers_stub = _types.ModuleType("loggers") +_loggers_stub.get_logger = lambda name: __import__("logging").getLogger(name) +sys.modules.setdefault("loggers", _loggers_stub) + +from fastapi import FastAPI +from fastapi.responses import StreamingResponse +from fastapi.testclient import TestClient + +import routes.preview as preview +from models.inference import ChatCompletionRequest + + +def _make_run(outputs: Path, name: str = "demorun") -> Path: + run = outputs / name + run.mkdir(parents = True) + (run / "adapter_config.json").write_text( + json.dumps({"base_model_name_or_path": "HuggingFaceTB/SmolLM-135M"}) + ) + ckpt = run / "checkpoint-1" + ckpt.mkdir() + (ckpt / "adapter_config.json").write_text("{}") + return run + + +@pytest.fixture +def captured(): + return {} + + +@pytest.fixture +def client(tmp_path, monkeypatch, captured): + outputs = tmp_path / "outputs" + _make_run(outputs) + + # resolve_preview_checkpoint -> resolve_output_dir -> outputs_root(). + from utils.paths import storage_roots as _sr + + monkeypatch.setattr(_sr, "outputs_root", lambda: outputs) + + async def _fake_load_model(load_req, request, subject): + captured["load_path"] = load_req.model_path + return None + + async def _fake_chat(payload, request, subject): + captured["payload"] = payload + return {"ok": True} + + monkeypatch.setattr(preview, "load_model", _fake_load_model) + monkeypatch.setattr(preview, "openai_chat_completions", _fake_chat) + + app = FastAPI() + app.include_router(preview.router, prefix = "/p") + app.dependency_overrides[preview.get_current_subject] = lambda: "admin" + # raise_server_exceptions=False so a 5xx surfaces as a response, not a throw. + return TestClient(app, raise_server_exceptions = False) + + +# ── Page rendering ──────────────────────────────────────────────────────── + + +def test_page_renders_with_csp(client): + r = client.get("/p/demorun") + assert r.status_code == 200 + assert "text/html" in r.headers["content-type"] + csp = r.headers.get("content-security-policy", "") + assert "default-src 'self'" in csp + assert "base-uri 'none'" in csp + + +def test_page_escapes_title(tmp_path, monkeypatch, captured): + outputs = tmp_path / "outputs" + # Run dir name carries an HTML-special char; the page must escape it. + _make_run(outputs, name = "a None. + outputs = tmp_path / "outputs" + merged = outputs / "mergedrun" + merged.mkdir(parents = True) + (merged / "config.json").write_text(json.dumps({"_name_or_path": "some/base"})) + + from utils.paths import storage_roots as _sr + + monkeypatch.setattr(_sr, "outputs_root", lambda: outputs) + + async def _fake_load(load_req, request, subject): + return None + + async def _fake_chat(payload, request, subject): + captured["payload"] = payload + return {"ok": True} + + monkeypatch.setattr(preview, "load_model", _fake_load) + monkeypatch.setattr(preview, "openai_chat_completions", _fake_chat) + + app = FastAPI() + app.include_router(preview.router, prefix = "/p") + c = TestClient(app, raise_server_exceptions = False) + r = c.post( + "/p/mergedrun/v1/chat/completions", + json = {"messages": [{"role": "user", "content": "hi"}], "use_adapter": False}, + ) + assert r.status_code == 200 + assert captured["payload"].use_adapter is None + + +# ── Streaming lock lifetime ────────────────────────────────────────────────── + + +def test_streaming_holds_lock_until_drained(tmp_path, monkeypatch, captured): + outputs = tmp_path / "outputs" + _make_run(outputs) + from utils.paths import storage_roots as _sr + + monkeypatch.setattr(_sr, "outputs_root", lambda: outputs) + + async def _fake_load_model(load_req, request, subject): + return None + + async def _gen(): + yield b"data: {}\n\n" + yield b"data: [DONE]\n\n" + + async def _fake_chat(payload, request, subject): + return StreamingResponse(_gen()) + + monkeypatch.setattr(preview, "load_model", _fake_load_model) + monkeypatch.setattr(preview, "openai_chat_completions", _fake_chat) + + async def _run(): + assert not preview._preview_lock.locked() + payload = ChatCompletionRequest(messages = [{"role": "user", "content": "hi"}]) + resp = await preview._serve_chat("demorun", None, payload, request = None) + # Lock must still be held: a second checkpoint must not swap the backend + # mid-stream. + assert preview._preview_lock.locked() + chunks = [c async for c in resp.body_iterator] + # Released only after the stream fully drains. + assert not preview._preview_lock.locked() + return chunks + + chunks = asyncio.run(_run()) + assert any(b"[DONE]" in c for c in chunks) + assert not preview._preview_lock.locked() diff --git a/studio/backend/utils/api_errors.py b/studio/backend/utils/api_errors.py index b1c55b61b9..cae8daf287 100644 --- a/studio/backend/utils/api_errors.py +++ b/studio/backend/utils/api_errors.py @@ -125,6 +125,12 @@ def is_anthropic_path(path: str) -> bool: return path.startswith("/v1/messages") +def wants_api_error_envelope(path: str) -> bool: + """True for the OpenAI/Anthropic-compatible surfaces: the ``/v1/*`` mount and + the preview ``/p/[/]/v1/*`` mount.""" + return path.startswith("/v1/") or (path.startswith("/p/") and "/v1/" in path) + + def error_body_for_path( path, message, @@ -183,15 +189,16 @@ def _summarize_validation_errors(errors) -> tuple: def install_api_error_handlers(app) -> None: """Register validation + HTTPException handlers that emit ``/v1/*`` envelopes. - Both handlers are global but only transform responses for paths starting with - ``/v1/``. Non-``/v1/`` paths reproduce FastAPI's default ``{"detail": ...}`` - behavior exactly so the Studio frontend keeps working. + Both handlers are global but only transform responses for OpenAI/Anthropic- + compatible surfaces (see :func:`wants_api_error_envelope`: the ``/v1/*`` mount + and the preview ``/p/.../v1/*`` mount). Every other path reproduces FastAPI's + default ``{"detail": ...}`` behavior exactly so the Studio frontend keeps working. """ @app.exception_handler(RequestValidationError) async def _handle_validation_error(request, exc): path = request.url.path - if path.startswith("/v1/"): + if wants_api_error_envelope(path): summary, param = _summarize_validation_errors(exc.errors()) return JSONResponse( status_code = 400, @@ -211,7 +218,7 @@ def install_api_error_handlers(app) -> None: # default http_exception_handler, which returns a bodiless Response. if not is_body_allowed_for_status_code(exc.status_code): return Response(status_code = exc.status_code, headers = headers) - if path.startswith("/v1/"): + if wants_api_error_envelope(path): detail = exc.detail # Already a fully-formed envelope: pass through untouched. if isinstance(detail, dict) and ("error" in detail or detail.get("type") == "error"): diff --git a/studio/backend/utils/models/checkpoints.py b/studio/backend/utils/models/checkpoints.py index 63f599d0df..d174f6677b 100644 --- a/studio/backend/utils/models/checkpoints.py +++ b/studio/backend/utils/models/checkpoints.py @@ -159,3 +159,64 @@ def scan_checkpoints( except Exception as e: logger.error(f"Error scanning checkpoints: {e}") return [] + + +def _is_model_dir(path: Path) -> bool: + return (path / "config.json").exists() or (path / "adapter_config.json").exists() + + +def has_preview_model(output_dir: Optional[str]) -> bool: + """True when ``output_dir`` holds a previewable root model (what ``/p/{run}`` + resolves). A cancelled run keeps ``output_dir`` but saves no root adapter.""" + if not output_dir: + return False + path = Path(output_dir) + return path.is_dir() and _is_model_dir(path) + + +def preview_ref(output_dir: Optional[str]) -> Optional[str]: + """``/p`` ref (``run`` or ``run/checkpoint``) relative to outputs_root, or None. + + Posix-joined so a nested output dir keeps a working link instead of collapsing + to its basename. None when not previewable, outside outputs_root, or deeper than + the two path segments the ``/p`` route matches (so the UI omits a dead link). + """ + if not has_preview_model(output_dir): + return None + try: + rel = Path(output_dir).resolve().relative_to(outputs_root().resolve()) + except (ValueError, OSError): + return None + parts = rel.parts + if not parts or len(parts) > 2: + return None + return "/".join(parts) + + +def resolve_preview_checkpoint(run: str, checkpoint: Optional[str] = None) -> Path: + relative = run if not checkpoint else f"{run}/{checkpoint}" + path = resolve_output_dir(relative) + if not path.is_dir() or not _is_model_dir(path): + raise FileNotFoundError( + f"No trained checkpoint at '{relative}'. Check the run/checkpoint name (see GET /p)." + ) + return path + + +def list_preview_targets(outputs_dir: str = str(outputs_root())) -> List[dict]: + targets: List[dict] = [] + for run_name, checkpoints, metadata in scan_checkpoints(outputs_dir): + for display_name, path, loss in checkpoints: + is_latest = display_name == run_name + checkpoint = None if is_latest else Path(path).name + targets.append( + { + "run": run_name, + "checkpoint": checkpoint, + "ref": run_name if is_latest else f"{run_name}/{checkpoint}", + "is_latest": is_latest, + "loss": loss, + "base_model": metadata.get("base_model"), + } + ) + return targets diff --git a/studio/frontend/src/features/studio/history-card-grid.tsx b/studio/frontend/src/features/studio/history-card-grid.tsx index dbe7fff01b..75ef1c2d85 100644 --- a/studio/frontend/src/features/studio/history-card-grid.tsx +++ b/studio/frontend/src/features/studio/history-card-grid.tsx @@ -24,7 +24,10 @@ import { useTrainingRuntimeStore, } from "@/features/training"; import { formatDuration } from "@/features/studio/sections/progress-section-lib"; +import { fetchDeviceType, usePlatformStore } from "@/config/env"; import { cn } from "@/lib/utils"; +import { copyToClipboard } from "@/lib/copy-to-clipboard"; +import { toast } from "@/lib/toast"; import { Delete02Icon } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { type ReactElement, useCallback, useEffect, useRef, useState } from "react"; @@ -194,6 +197,28 @@ export function HistoryCardGrid({ const [manualFetchInFlight, setManualFetchInFlight] = useState(false); const { resumeTrainingRunFromHistory } = useTrainingActions(); const isStarting = useTrainingRuntimeStore((state) => state.isStarting); + // Copy-link base: Cloudflare tunnel > LAN host:port > origin. The tunnel + // registers shortly after startup, so poll (bounded) until it shows. + const cloudflareUrl = usePlatformStore((s) => s.cloudflareUrl); + const serverUrl = usePlatformStore((s) => s.serverUrl); + useEffect(() => { + if (cloudflareUrl) return; + let cancelled = false; + void (async () => { + for (let attempt = 0; attempt < 12 && !cancelled; attempt++) { + try { + await fetchDeviceType({ force: true }); + } catch { + // Ignore startup blips; copy-link falls back to serverUrl/origin. + } + if (cancelled || usePlatformStore.getState().cloudflareUrl) return; + await new Promise((r) => setTimeout(r, 2500)); + } + })(); + return () => { + cancelled = true; + }; + }, [cloudflareUrl]); const userControllerRef = useRef(null); const pollControllerRef = useRef(null); @@ -362,6 +387,8 @@ export function HistoryCardGrid({ const isRunning = run.status === "running"; const canResume = run.can_resume && !wasContinued; const isResuming = resumeTarget === run.id; + // Backend /p ref, gated on previewability + route-expressible depth. + const canCopyPreview = !!run.preview_ref; return (
onSelectRun(run.id)} onKeyDown={(e) => { @@ -411,6 +438,38 @@ export function HistoryCardGrid({ {isResuming ? t("studio.history.resuming") : t("studio.history.resumeTraining")} )} + {canCopyPreview && ( + + )}

{run.loss_sparkline && run.loss_sparkline.length >= 2 && ( -
+
Date: Wed, 24 Jun 2026 06:37:41 -0700 Subject: [PATCH 54/56] Verify DiffusionGemma visual-server binary against approved checksums (#6635) ensure_diffusion_visual_server() downloaded the visual-server release asset with the unverified download_file() and marked it executable, bypassing the approved-checksum manifest that gates every other prebuilt llama.cpp artifact. The backend later auto-discovers that binary and launches it through DG_VISUAL_BIN, so a compromised or substituted release asset could place attacker-controlled native code in the install tree and have it executed under the Studio user. Require the matched asset to be present in the approved checksum manifest and download it through download_file_verified() with the published sha256. A name-matching asset that is absent from the manifest is refused rather than executed. Add regression tests covering the verified-download path and the refusal of an unapproved asset. --- studio/install_llama_prebuilt.py | 44 +++++-- .../test_install_llama_prebuilt_logic.py | 119 ++++++++++++++++++ 2 files changed, 154 insertions(+), 9 deletions(-) diff --git a/studio/install_llama_prebuilt.py b/studio/install_llama_prebuilt.py index 33cb709ba7..c7f34e39f2 100644 --- a/studio/install_llama_prebuilt.py +++ b/studio/install_llama_prebuilt.py @@ -4261,7 +4261,10 @@ def ensure_converter_scripts(install_dir: Path, llama_tag: str) -> None: def ensure_diffusion_visual_server( - install_dir: Path, host: HostInfo, release_tag: str | None + install_dir: Path, + host: HostInfo, + release_tag: str | None, + approved_checksums: ApprovedReleaseChecksums, ) -> None: """Best-effort placement of the DiffusionGemma visual-server binary next to llama-server in the install tree, so Studio can serve DiffusionGemma GGUFs @@ -4293,6 +4296,7 @@ def ensure_diffusion_visual_server( try: assets = github_release_assets(DEFAULT_PUBLISHED_REPO, release_tag) match = None + unapproved_matches: list[str] = [] for asset_name, url in assets.items(): low = asset_name.lower() if "llama-diffusion-gemma-visual-server" not in low: @@ -4301,19 +4305,39 @@ def ensure_diffusion_visual_server( continue if (not host.is_windows) and low.endswith(".exe"): continue - match = (asset_name, url) + # This binary is chmod'd executable and later launched by the + # backend, so it must be covered by the approved checksum manifest + # just like every other prebuilt artifact. An asset that matches the + # name but is missing from the manifest is refused rather than run. + approved = approved_checksums.artifacts.get(asset_name) + if approved is None: + unapproved_matches.append(asset_name) + continue + match = (asset_name, url, approved.sha256) break if match is None: - log( - "diffusion visual server not found in the published release; native " - "DiffusionGemma serving needs DG_VISUAL_BIN or a source build" - ) + if unapproved_matches: + log( + "diffusion visual server asset(s) were present but omitted from the " + "approved checksum manifest; refusing unverified native executable: " + + ", ".join(unapproved_matches) + ) + else: + log( + "diffusion visual server not found in the published release; native " + "DiffusionGemma serving needs DG_VISUAL_BIN or a source build" + ) return bin_dir.mkdir(parents = True, exist_ok = True) - download_file(match[1], target) + download_file_verified( + match[1], + target, + expected_sha256 = match[2], + label = f"diffusion visual server {match[0]}", + ) if not host.is_windows: target.chmod(0o755) - log(f"installed diffusion visual server: {match[0]}") + log(f"installed verified diffusion visual server: {match[0]}") except Exception as exc: log( "diffusion visual server fetch skipped " @@ -6637,7 +6661,9 @@ def install_prebuilt( f"({textwrap.shorten(str(exc), width = 200, placeholder = '...')})" ) try: - ensure_diffusion_visual_server(install_dir, host, plan.release_tag) + ensure_diffusion_visual_server( + install_dir, host, plan.release_tag, plan.approved_checksums + ) except Exception as exc: log( "diffusion visual server step skipped; install remains valid " diff --git a/tests/studio/install/test_install_llama_prebuilt_logic.py b/tests/studio/install/test_install_llama_prebuilt_logic.py index a5a1131ecc..9ee8759bb4 100644 --- a/tests/studio/install/test_install_llama_prebuilt_logic.py +++ b/tests/studio/install/test_install_llama_prebuilt_logic.py @@ -37,6 +37,41 @@ install_prebuilt = INSTALL_LLAMA_PREBUILT.install_prebuilt write_prebuilt_metadata = INSTALL_LLAMA_PREBUILT.write_prebuilt_metadata existing_install_matches_plan = INSTALL_LLAMA_PREBUILT.existing_install_matches_plan existing_install_matches_choice = INSTALL_LLAMA_PREBUILT.existing_install_matches_choice +ensure_diffusion_visual_server = INSTALL_LLAMA_PREBUILT.ensure_diffusion_visual_server + + +def linux_host() -> HostInfo: + return HostInfo( + system = "Linux", + machine = "x86_64", + is_windows = False, + is_linux = True, + is_macos = False, + is_x86_64 = True, + is_arm64 = False, + nvidia_smi = None, + driver_cuda_version = None, + compute_caps = [], + visible_cuda_devices = None, + has_physical_nvidia = False, + has_usable_nvidia = False, + ) + + +def approved_release_checksums_for_asset(asset_name: str, sha256: str) -> ApprovedReleaseChecksums: + return ApprovedReleaseChecksums( + repo = "unslothai/llama.cpp", + release_tag = "b9334", + upstream_tag = "b9334", + artifacts = { + asset_name: ApprovedArtifactHash( + asset_name = asset_name, + sha256 = sha256, + repo = "unslothai/llama.cpp", + kind = "diffusion-visual-server", + ) + }, + ) def approved_checksums_for( @@ -2828,3 +2863,87 @@ def test_validate_prebuilt_choice_approved_validation_runs_when_flag_enabled(tmp monkeypatch.setattr(INSTALL_LLAMA_PREBUILT, "_RUN_STAGED_PREBUILT_VALIDATION", True) calls = _run_validate_prebuilt_choice(monkeypatch, tmp_path, expected_sha256 = "ab" * 32) assert calls == {"quantize": 1, "server": 1} + + +def test_diffusion_visual_server_uses_approved_checksum_download(monkeypatch, tmp_path: Path): + asset_name = "llama-diffusion-gemma-visual-server-linux-x64" + expected_sha = "a" * 64 + asset_url = "https://github.com/unslothai/llama.cpp/releases/download/b9334/" + asset_name + calls: list[tuple[str, Path, str | None, str | None]] = [] + + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "github_release_assets", + lambda repo, tag: {asset_name: asset_url}, + ) + + def fake_download_file(url, destination): + raise AssertionError("diffusion visual server must not use unverified download_file") + + def fake_download_file_verified(url, destination, *, expected_sha256, label): + calls.append((url, Path(destination), expected_sha256, label)) + Path(destination).write_bytes(b"verified visual server") + + monkeypatch.setattr(INSTALL_LLAMA_PREBUILT, "download_file", fake_download_file) + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, "download_file_verified", fake_download_file_verified + ) + + ensure_diffusion_visual_server( + tmp_path / "install", + linux_host(), + "b9334", + approved_release_checksums_for_asset(asset_name, expected_sha), + ) + + target = tmp_path / "install" / "build" / "bin" / "llama-diffusion-gemma-visual-server" + assert calls == [ + ( + asset_url, + target, + expected_sha, + f"diffusion visual server {asset_name}", + ) + ] + assert target.read_bytes() == b"verified visual server" + assert target.stat().st_mode & 0o777 == 0o755 + + +def test_diffusion_visual_server_refuses_unapproved_release_asset(monkeypatch, tmp_path: Path): + asset_name = "llama-diffusion-gemma-visual-server-attacker-linux" + verified_calls: list[str] = [] + raw_calls: list[str] = [] + + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "github_release_assets", + lambda repo, tag: {asset_name: "https://example.test/" + asset_name}, + ) + + def fake_download_file(url, destination): + raw_calls.append(url) + + def fake_download_file_verified(url, destination, *, expected_sha256, label): + verified_calls.append(url) + + monkeypatch.setattr(INSTALL_LLAMA_PREBUILT, "download_file", fake_download_file) + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, "download_file_verified", fake_download_file_verified + ) + + ensure_diffusion_visual_server( + tmp_path / "install", + linux_host(), + "b9334", + ApprovedReleaseChecksums( + repo = "unslothai/llama.cpp", + release_tag = "b9334", + upstream_tag = "b9334", + artifacts = {}, + ), + ) + + target = tmp_path / "install" / "build" / "bin" / "llama-diffusion-gemma-visual-server" + assert not target.exists() + assert raw_calls == [] + assert verified_calls == [] From ab6c9ecfee545869d56cc6eddd1babc3f7f36fba Mon Sep 17 00:00:00 2001 From: oobabooga Date: Wed, 24 Jun 2026 11:37:08 -0300 Subject: [PATCH 55/56] Studio: honor `stream=false` on the GGUF agentic tool path (#6570) (#6618) * Studio: honor stream=false on the GGUF agentic tool path (#6570) * Studio: dedup the #6570 non-streaming tool tests and cover cached_tokens * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: cover the cached_tokens metadata fix and clarify the drain comment (#6570) * Studio: align the GGUF tool drain naming and tighten its comment (#6570) --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com> --- studio/backend/core/inference/llama_cpp.py | 15 +- studio/backend/routes/inference.py | 126 ++++++++++++- .../tests/test_gguf_tool_non_streaming.py | 172 ++++++++++++++++++ .../backend/tests/test_llama_cpp_tool_loop.py | 77 ++++++++ .../tests/test_openai_tool_passthrough.py | 2 + 5 files changed, 378 insertions(+), 14 deletions(-) create mode 100644 studio/backend/tests/test_gguf_tool_non_streaming.py diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 9c22db4fec..152a3f19b2 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -7881,13 +7881,18 @@ class LlamaCppBackend: _mt["predicted_per_second"] = _mt["predicted_n"] / ( _mt["predicted_ms"] / 1000.0 ) + _usage = { + "prompt_tokens": _fp, + "completion_tokens": _tc, + "total_tokens": _fp + _tc, + } + # Preserve KV-cache hit details (cached_tokens) so the tool path + # reports them like the standard non-tool path does, not always 0. + if _fu.get("prompt_tokens_details"): + _usage["prompt_tokens_details"] = _fu["prompt_tokens_details"] return { "type": "metadata", - "usage": { - "prompt_tokens": _fp, - "completion_tokens": _tc, - "total_tokens": _fp + _tc, - }, + "usage": _usage, "timings": _mt, "finish_reason": finish_reason, } diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 2e2c38933e..8b0981cd2b 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -5425,15 +5425,123 @@ async def openai_chat_completions( pass _tracker.__exit__(None, None, None) - return _SameTaskStreamingResponse( - gguf_tool_stream(), - media_type = "text/event-stream", - headers = { - "Cache-Control": "no-cache", - "Connection": "close", - "X-Accel-Buffering": "no", - }, - ) + if payload.stream: + return _SameTaskStreamingResponse( + gguf_tool_stream(), + media_type = "text/event-stream", + headers = { + "Cache-Control": "no-cache", + "Connection": "close", + "X-Accel-Buffering": "no", + }, + ) + + # Non-streaming JSON: drain the agentic generator into one + # ChatCompletion, like the standard GGUF `else` branch. stream:false + # with tools enabled used to return an SSE body, breaking + # non-streaming clients; `unsloth studio run --model` forces tools on + # process-wide, so plain requests reach this path (#6570). + def _drain_gguf_tool_loop(): + full_text = "" + usage = None + finish = None + gen = gguf_generate_with_tools() + try: + for event in gen: + if cancel_event.is_set(): + break + if event.get("type") == "metadata": + usage = event.get("usage") + finish = event.get("finish_reason") + elif event.get("type") == "content": + # Content is cumulative within a turn and resets + # between turns, so the last event holds the final + # turn's text. As in the safetensors drain, a visible + # preamble emitted before a tool call (its own earlier + # turn) isn't carried -- only the final turn is. + full_text = _strip_tool_xml_for_display( + event.get("text", ""), + auto_heal_tool_calls = _gguf_auto_heal_tool_calls, + ) + return full_text, usage, finish + finally: + # Close the generator on early break/cancel so the underlying + # llama-server stream socket is released, like the SSE path. + try: + gen.close() + except (RuntimeError, ValueError): + pass + + try: + full_text, completion_usage, completion_finish = await asyncio.to_thread( + _drain_gguf_tool_loop + ) + reasoning_text, visible_text = _extract_responses_reasoning( + full_text, + parse_think_markers = _responses_should_parse_think_markers( + payload, llama_backend + ), + ) + message_kwargs = {"content": visible_text} + if reasoning_text: + message_kwargs["reasoning_content"] = reasoning_text + _usage = completion_usage or {} + _prompt_tokens = _usage.get("prompt_tokens") or 0 + _completion_tokens = _usage.get("completion_tokens") or 0 + response = ChatCompletion( + id = completion_id, + created = created, + model = model_name, + choices = [ + CompletionChoice( + message = CompletionMessage(**message_kwargs), + finish_reason = _clamp_finish_reason(completion_finish), + ) + ], + usage = CompletionUsage( + prompt_tokens = _prompt_tokens, + completion_tokens = _completion_tokens, + total_tokens = _prompt_tokens + _completion_tokens, + prompt_tokens_details = _prompt_tokens_details( + _usage.get("prompt_tokens_details") + ), + ), + ) + api_monitor.set_reply(monitor_id, visible_text) + _monitor_usage( + monitor_id, + { + "prompt_tokens": _prompt_tokens, + "completion_tokens": _completion_tokens, + "total_tokens": _prompt_tokens + _completion_tokens, + }, + _monitor_context_length(), + ) + api_monitor.finish( + monitor_id, "cancelled" if cancel_event.is_set() else "completed" + ) + return _model_json_response(response) + except Exception as e: + logger.error(f"Error during GGUF tool completion: {e}", exc_info = True) + api_monitor.fail(monitor_id, _friendly_error(e)) + # Recover if an MTP+tensor crash killed the server. + get_llama_cpp_backend()._maybe_recover_from_mtp_crash(e) + # An over-context prompt makes llama-server return 400; map any + # upstream 4xx to a 400 client error rather than leaking a 500. + _cls = _classify_llama_generation_error(e) + if _cls is not None: + raise HTTPException( + status_code = 400, + detail = openai_error_body( + _friendly_error(e), + status = 400, + code = "context_length_exceeded" if _cls else None, + param = "messages", + ), + ) + raise HTTPException(status_code = 500, detail = safe_error_detail(e)) + finally: + _tracker.__exit__(None, None, None) # ── Standard GGUF path (no tools) ───────────────────── diff --git a/studio/backend/tests/test_gguf_tool_non_streaming.py b/studio/backend/tests/test_gguf_tool_non_streaming.py new file mode 100644 index 0000000000..d9044824cb --- /dev/null +++ b/studio/backend/tests/test_gguf_tool_non_streaming.py @@ -0,0 +1,172 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""Regression tests for `stream:false` on the GGUF agentic tool path (#6570). + +When server-side tools are enabled (e.g. `unsloth studio run --model ...`, +which forces the tool policy on process-wide), a plain chat request used to be +routed into the tool loop, which returned an SSE body *regardless* of +`stream:false` -- breaking non-streaming clients and health checks like +LiteLLM. These tests drive the real route with a fake tool-capable backend and +assert the non-streaming path now returns a single JSON `chat.completion`, +while `stream:true` still streams. +""" + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from auth.authentication import get_current_subject +import routes.inference as inference_route + + +class _ToolGgufBackend: + is_loaded = True + model_identifier = "test/model.gguf" + _is_audio = False + is_vision = False + supports_tools = True + + def generate_chat_completion_with_tools(self, **kwargs): + # The agentic loop runs one tool, then the model answers. Event shapes + # mirror the real GGUF loop (tool_start/tool_end/content/metadata). + yield { + "type": "tool_start", + "tool_name": "python", + "tool_call_id": "call_1", + "arguments": {"code": "print(6 * 7)"}, + } + yield { + "type": "tool_end", + "tool_name": "python", + "tool_call_id": "call_1", + "result": "42\n", + } + yield {"type": "content", "text": "The answer is 42."} + yield { + "type": "metadata", + "usage": {"prompt_tokens": 11, "completion_tokens": 5, "total_tokens": 16}, + "timings": {"prompt_n": 11, "predicted_n": 5}, + "finish_reason": "stop", + } + + +def _client(monkeypatch, backend = None): + monkeypatch.setattr( + inference_route, "get_llama_cpp_backend", lambda: backend or _ToolGgufBackend() + ) + # Tools forced on -- the same effect as the CLI `run --model` tool policy. + monkeypatch.setattr(inference_route, "_effective_enable_tools", lambda payload: True) + + async def _fake_select(payload, **_kwargs): + return [{"type": "function", "function": {"name": "python"}}] + + monkeypatch.setattr(inference_route, "_select_request_tools", _fake_select) + + app = FastAPI() + app.include_router(inference_route.router) + app.dependency_overrides[get_current_subject] = lambda: "test-user" + return TestClient(app) + + +def _payload(stream: bool): + return { + "messages": [{"role": "user", "content": "What is 6 * 7? Use python."}], + "stream": stream, + "enable_tools": True, + } + + +def test_non_streaming_tool_call_returns_single_json(monkeypatch): + response = _client(monkeypatch).post("/chat/completions", json = _payload(stream = False)) + + assert response.status_code == 200 + # The bug returned text/event-stream here; it must be a single JSON object. + assert response.headers["content-type"].startswith("application/json") + + body = response.json() + assert body["object"] == "chat.completion" + choice = body["choices"][0] + assert choice["message"]["content"] == "The answer is 42." + assert choice["finish_reason"] == "stop" + assert body["usage"]["prompt_tokens"] == 11 + assert body["usage"]["completion_tokens"] == 5 + assert body["usage"]["total_tokens"] == 16 + + +def test_streaming_tool_call_still_streams(monkeypatch): + # The parallel path is untouched: stream:true keeps returning SSE. + response = _client(monkeypatch).post("/chat/completions", json = _payload(stream = True)) + + assert response.status_code == 200 + assert response.headers["content-type"].startswith("text/event-stream") + assert "The answer is 42." in response.text + assert "data: [DONE]" in response.text + + +class _EventsBackend(_ToolGgufBackend): + """Tool backend that yields a caller-supplied event list.""" + + def __init__(self, events): + self._events = events + + def generate_chat_completion_with_tools(self, **kwargs): + yield from self._events + + +def test_non_streaming_missing_usage_defaults_to_zero(monkeypatch): + # No metadata event at all: usage zero-defaults and finish_reason falls back. + events = [{"type": "content", "text": "hi"}] + response = _client(monkeypatch, _EventsBackend(events)).post( + "/chat/completions", json = _payload(stream = False) + ) + + assert response.status_code == 200 + body = response.json() + assert body["choices"][0]["message"]["content"] == "hi" + assert body["choices"][0]["finish_reason"] == "stop" + assert body["usage"]["prompt_tokens"] == 0 + assert body["usage"]["completion_tokens"] == 0 + assert body["usage"]["total_tokens"] == 0 + + +def test_non_streaming_preserves_length_finish_reason(monkeypatch): + events = [ + {"type": "content", "text": "truncated"}, + { + "type": "metadata", + "usage": {"prompt_tokens": 3, "completion_tokens": 9}, + "finish_reason": "length", + }, + ] + response = _client(monkeypatch, _EventsBackend(events)).post( + "/chat/completions", json = _payload(stream = False) + ) + + assert response.status_code == 200 + body = response.json() + assert body["choices"][0]["finish_reason"] == "length" + # total_tokens is derived when the server omits it. + assert body["usage"]["total_tokens"] == 12 + + +def test_non_streaming_preserves_cached_tokens(monkeypatch): + # KV-cache hit details from the metadata event must survive into the body + # (the tool path used to drop them and always report cached_tokens=0). + events = [ + {"type": "content", "text": "hi"}, + { + "type": "metadata", + "usage": { + "prompt_tokens": 20, + "completion_tokens": 4, + "prompt_tokens_details": {"cached_tokens": 16}, + }, + "finish_reason": "stop", + }, + ] + response = _client(monkeypatch, _EventsBackend(events)).post( + "/chat/completions", json = _payload(stream = False) + ) + + assert response.status_code == 200 + assert response.json()["usage"]["prompt_tokens_details"]["cached_tokens"] == 16 diff --git a/studio/backend/tests/test_llama_cpp_tool_loop.py b/studio/backend/tests/test_llama_cpp_tool_loop.py index 56e028bd5a..05d2a0b80a 100644 --- a/studio/backend/tests/test_llama_cpp_tool_loop.py +++ b/studio/backend/tests/test_llama_cpp_tool_loop.py @@ -1736,3 +1736,80 @@ def test_empty_tool_call_id_does_not_emit_provisional_card(monkeypatch): assert provisional == [] # The real call still executes despite the missing id. assert calls == [("python", {"code": big_code})] + + +def _usage_done(usage: dict, finish_reason: str = "stop") -> str: + """A terminal SSE chunk carrying llama-server's ``usage`` block, the way the + real server reports it on the final chunk of a completion.""" + return ( + "data: " + + json.dumps( + { + "choices": [{"index": 0, "delta": {}, "finish_reason": finish_reason}], + "usage": usage, + } + ) + + "\n" + ) + + +def test_metadata_event_preserves_prompt_tokens_details(monkeypatch): + """The tool loop's metadata event must carry llama-server's + ``prompt_tokens_details`` (KV-cache hits) through ``_build_metadata_event``, + so the route reports real ``cached_tokens`` instead of always 0 (#6570). + + This drives the *real* generator; the route-level test feeds a pre-built + metadata event and so never exercises this code. + """ + stream = [ + _sse({"content": "The answer is 42."}), + _usage_done( + { + "prompt_tokens": 20, + "completion_tokens": 4, + "prompt_tokens_details": {"cached_tokens": 16}, + } + ), + _done(), + ] + payloads: list[dict] = [] + backend = _make_backend(monkeypatch, [stream], payloads) + + events = list( + backend.generate_chat_completion_with_tools( + messages = [{"role": "user", "content": "hi"}], + tools = [], + max_tool_iterations = 1, + ) + ) + + metadata = [e for e in events if e.get("type") == "metadata"] + assert metadata, "expected a metadata event" + usage = metadata[-1]["usage"] + assert usage["prompt_tokens_details"] == {"cached_tokens": 16} + assert usage["prompt_tokens"] == 20 + assert usage["completion_tokens"] == 4 + + +def test_metadata_event_omits_prompt_tokens_details_when_absent(monkeypatch): + """No KV-cache block from the server -> the key isn't fabricated, so the + route falls back to its 0-default instead of reading a bogus value.""" + stream = [ + _sse({"content": "hi"}), + _usage_done({"prompt_tokens": 5, "completion_tokens": 2}), + _done(), + ] + payloads: list[dict] = [] + backend = _make_backend(monkeypatch, [stream], payloads) + + events = list( + backend.generate_chat_completion_with_tools( + messages = [{"role": "user", "content": "hi"}], + tools = [], + max_tool_iterations = 1, + ) + ) + + metadata = [e for e in events if e.get("type") == "metadata"] + assert metadata, "expected a metadata event" + assert "prompt_tokens_details" not in metadata[-1]["usage"] diff --git a/studio/backend/tests/test_openai_tool_passthrough.py b/studio/backend/tests/test_openai_tool_passthrough.py index aaef9e4dcc..aa36c6fed4 100644 --- a/studio/backend/tests/test_openai_tool_passthrough.py +++ b/studio/backend/tests/test_openai_tool_passthrough.py @@ -1349,6 +1349,7 @@ class TestGgufVisionToolRouting: model = "default", enable_tools = True, enabled_tools = ["web_search"], + stream = True, messages = [ { "role": "user", @@ -1408,6 +1409,7 @@ class TestGgufVisionToolRouting: enable_tools = True, enabled_tools = ["web_search"], parallel_tool_calls = False, + stream = True, messages = [{"role": "user", "content": "search once"}], ) From a3954edd15e4a03b584d60940173b99c17f45922 Mon Sep 17 00:00:00 2001 From: Long Yixing Date: Wed, 24 Jun 2026 22:39:20 +0800 Subject: [PATCH 56/56] Fix Studio GGUF variant expansion crash (#6636) * fix: handle empty GGUF variants * fix: gate local GGUF expansion * fix: normalize GGUF variant payload --------- Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com> --- .../assistant-ui/model-selector/pickers.tsx | 83 +++++++++++++++---- 1 file changed, 68 insertions(+), 15 deletions(-) diff --git a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx index 90c1c04106..386f233c7b 100644 --- a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx +++ b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx @@ -570,6 +570,52 @@ function ModelRow({ // ── GGUF Variant Expander ──────────────────────────────────── +function isValidGgufVariant(variant: unknown): variant is GgufVariantDetail { + if (!variant || typeof variant !== "object") return false; + const candidate = variant as Partial; + return ( + typeof candidate.filename === "string" && + candidate.filename.length > 0 && + typeof candidate.quant === "string" && + candidate.quant.length > 0 && + typeof candidate.size_bytes === "number" && + Number.isFinite(candidate.size_bytes) && + candidate.size_bytes >= 0 && + (candidate.downloaded === undefined || + typeof candidate.downloaded === "boolean") + ); +} + +function normalizeGgufVariantsResponse(res: { + variants?: unknown; + default_variant?: unknown; + has_vision?: unknown; + context_length?: unknown; +} | null | undefined): { + variants: GgufVariantDetail[]; + defaultVariant: string | null; + hasVision: boolean; + contextLength: number | null; +} { + const contextLength = res?.context_length; + return { + variants: (Array.isArray(res?.variants) ? res.variants : []).filter( + isValidGgufVariant, + ), + defaultVariant: + typeof res?.default_variant === "string" && res.default_variant.length > 0 + ? res.default_variant + : null, + hasVision: res?.has_vision === true, + contextLength: + typeof contextLength === "number" && + Number.isFinite(contextLength) && + contextLength >= 0 + ? contextLength + : null, + }; +} + function GgufVariantExpander({ repoId, onSelect, @@ -622,11 +668,12 @@ function GgufVariantExpander({ listGgufVariants(repoId) .then((res) => { if (canceled) return; - setVariants(res.variants); - setDefaultVariant(res.default_variant); - setHasVision(res.has_vision); - onHasVision?.(res.has_vision); - setNativeContext(res.context_length ?? null); + const normalized = normalizeGgufVariantsResponse(res); + setVariants(normalized.variants); + setDefaultVariant(normalized.defaultVariant); + setHasVision(normalized.hasVision); + onHasVision?.(normalized.hasVision); + setNativeContext(normalized.contextLength); }) .catch((err) => { if (canceled) return; @@ -694,19 +741,25 @@ function GgufVariantExpander({ // If the recommended variant is OOM, pick the largest fitting one; // if all are OOM, recommend the smallest. const effectiveRecommended = useMemo(() => { - if (!variants || totalBudgetGb <= 0) return defaultVariant; + if (!variants || variants.length === 0 || totalBudgetGb <= 0) { + return defaultVariant; + } const defaultV = variants.find((v) => v.quant === defaultVariant); if (defaultV && getGgufFit(defaultV.size_bytes) !== "oom") return defaultVariant; // Largest non-OOM variant (best quality that fits) - const fitting = variants.filter((v) => getGgufFit(v.size_bytes) !== "oom"); + const fitting = variants.filter( + (v) => getGgufFit(v.size_bytes) !== "oom", + ); if (fitting.length > 0) { fitting.sort((a, b) => b.size_bytes - a.size_bytes); return fitting[0].quant; } // All OOM -- recommend smallest (most likely to partially run) - const sorted = [...variants].sort((a, b) => a.size_bytes - b.size_bytes); - return sorted[0].quant; + const sorted = [...variants].sort( + (a, b) => a.size_bytes - b.size_bytes, + ); + return sorted[0]?.quant ?? defaultVariant; }, [variants, defaultVariant, totalBudgetGb, getGgufFit]); const sortedVariants = useMemo(() => { @@ -2901,7 +2954,7 @@ export function HubModelPicker({ } }} onArrowDownIntoChildren={ - isGgufExpanded(m.id) + isGguf && !isDirectGguf && isGgufExpanded(m.id) ? () => { const focused = focusFirstChildOption(optionKey); @@ -2911,7 +2964,7 @@ export function HubModelPicker({ } vramStatus={null} /> - {isGgufExpanded(m.id) && ( + {isGguf && !isDirectGguf && isGgufExpanded(m.id) && ( { const focused = focusFirstChildOption(optionKey); @@ -2998,7 +3051,7 @@ export function HubModelPicker({ } vramStatus={null} /> - {!isGgufFile && isGgufExpanded(m.id) && ( + {isGguf && !isGgufFile && isGgufExpanded(m.id) && ( focusFirstChildOption(optionKey) : undefined } vramStatus={null} /> - {!isGgufFile && isGgufExpanded(m.id) && ( + {isGguf && !isGgufFile && isGgufExpanded(m.id) && (