From 643e13ac334f726d25a12358ca6c2abf6f313b28 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Mon, 22 Jun 2026 09:15:22 -0700 Subject: [PATCH 001/192] Bump install.sh / install.ps1 pin to unsloth>=2026.6.9 (#6580) --- install.ps1 | 10 +++++----- install.sh | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/install.ps1 b/install.ps1 index 765f33b1ff..efb54efa2b 100644 --- a/install.ps1 +++ b/install.ps1 @@ -2146,7 +2146,7 @@ exit 0 if ($SkipTorch) { # No-torch: install unsloth + unsloth-zoo with --no-deps, then # runtime deps (typer, safetensors, transformers, etc.) with --no-deps. - $baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (migrated no-torch)" { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.6.8" "unsloth-zoo>=2026.6.6" } + $baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (migrated no-torch)" { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.6.9" "unsloth-zoo>=2026.6.7" } if ($baseInstallExit -eq 0) { # Resolve pydantic WITH deps so pip pins pydantic-core # to the matching version (no-torch-runtime.txt below @@ -2160,7 +2160,7 @@ exit 0 } } } else { - $baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (migrated)" { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.6.8" "unsloth-zoo>=2026.6.6" } + $baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (migrated)" { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.6.9" "unsloth-zoo>=2026.6.7" } } if ($baseInstallExit -ne 0) { Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red @@ -2226,7 +2226,7 @@ exit 0 if ($SkipTorch) { # No-torch: install unsloth + unsloth-zoo with --no-deps, then # runtime deps (typer, safetensors, transformers, etc.) with --no-deps. - $baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (no-torch)" { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.6.8" "unsloth-zoo>=2026.6.6" } + $baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (no-torch)" { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.6.9" "unsloth-zoo>=2026.6.7" } if ($baseInstallExit -eq 0) { # Same pydantic-with-deps trick as the migrated branch. $baseInstallExit = Invoke-InstallCommandRetry -Label "install pydantic" { uv pip install --python $VenvPython pydantic } @@ -2238,7 +2238,7 @@ exit 0 } } } elseif ($StudioLocalInstall) { - $baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (local)" { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.6.8" "unsloth-zoo>=2026.6.6" } + $baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (local)" { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.6.9" "unsloth-zoo>=2026.6.7" } } else { $baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth" { uv pip install --python $VenvPython --upgrade-package unsloth -- "$PackageName" } } @@ -2266,7 +2266,7 @@ exit 0 Write-TauriLog "STEP" "Installing unsloth" substep "installing unsloth (this may take a few minutes)..." if ($StudioLocalInstall) { - $baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (auto torch backend)" { uv pip install --python $VenvPython "unsloth-zoo>=2026.6.6" "unsloth>=2026.6.8" --torch-backend=auto } + $baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (auto torch backend)" { uv pip install --python $VenvPython "unsloth-zoo>=2026.6.7" "unsloth>=2026.6.9" --torch-backend=auto } if ($baseInstallExit -ne 0) { Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red return (Exit-InstallFailure "Failed to install unsloth (exit code $baseInstallExit)" $baseInstallExit) diff --git a/install.sh b/install.sh index 7a2e18e374..a3b2734dac 100755 --- a/install.sh +++ b/install.sh @@ -2621,7 +2621,7 @@ if [ "$_MIGRATED" = true ]; then # to prevent transitive torch resolution. run_install_cmd_retry "install unsloth (migrated no-torch)" uv pip install --python "$_VENV_PY" --no-deps \ --reinstall-package unsloth --reinstall-package unsloth-zoo \ - "unsloth>=2026.6.8" "unsloth-zoo>=2026.6.6" + "unsloth>=2026.6.9" "unsloth-zoo>=2026.6.7" # Resolve pydantic WITH deps so pip pins pydantic-core to the # matching version (no-torch-runtime.txt below is --no-deps). # All transitive deps are torch-free. @@ -2634,7 +2634,7 @@ if [ "$_MIGRATED" = true ]; then else run_install_cmd_retry "install unsloth (migrated)" uv pip install --python "$_VENV_PY" \ --reinstall-package unsloth --reinstall-package unsloth-zoo \ - "unsloth>=2026.6.8" "unsloth-zoo>=2026.6.6" + "unsloth>=2026.6.9" "unsloth-zoo>=2026.6.7" fi if [ "$STUDIO_LOCAL_INSTALL" = true ]; then substep "overlaying local repo (editable)..." @@ -2838,7 +2838,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then # runtime deps (typer, safetensors, transformers, etc.) with --no-deps. run_install_cmd_retry "install unsloth (no-torch)" uv pip install --python "$_VENV_PY" --no-deps \ --upgrade-package unsloth --upgrade-package unsloth-zoo \ - "unsloth>=2026.6.8" "unsloth-zoo>=2026.6.6" + "unsloth>=2026.6.9" "unsloth-zoo>=2026.6.7" # Same pydantic-with-deps trick as the migrated branch. run_install_cmd_retry "install pydantic (with deps for compatible core)" \ uv pip install --python "$_VENV_PY" pydantic @@ -2856,7 +2856,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then fi elif [ "$STUDIO_LOCAL_INSTALL" = true ]; then run_install_cmd_retry "install unsloth (local)" uv pip install --python "$_VENV_PY" \ - --upgrade-package unsloth "unsloth>=2026.6.8" "unsloth-zoo>=2026.6.6" + --upgrade-package unsloth "unsloth>=2026.6.9" "unsloth-zoo>=2026.6.7" substep "overlaying local repo (editable)..." run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps substep "overlaying unsloth-zoo from git main..." @@ -2888,7 +2888,7 @@ else tauri_log "STEP" "Installing Unsloth" substep "installing unsloth (this may take a few minutes)..." if [ "$STUDIO_LOCAL_INSTALL" = true ]; then - run_install_cmd_retry "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" "unsloth-zoo>=2026.6.6" "unsloth>=2026.6.8" --torch-backend=auto + run_install_cmd_retry "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" "unsloth-zoo>=2026.6.7" "unsloth>=2026.6.9" --torch-backend=auto substep "overlaying local repo (editable)..." run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps substep "overlaying unsloth-zoo from git main..." 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 002/192] 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 003/192] 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 055/192] 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 056/192] 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 057/192] 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) && ( Date: Wed, 24 Jun 2026 17:34:18 -0700 Subject: [PATCH 058/192] Installer: make UV_OVERRIDE space-safe on Apple Silicon (#6503) (#6639) * Installer: make UV_OVERRIDE space-safe on Apple Silicon (#6503) On Apple Silicon, install.sh exports UV_OVERRIDE pointing at the bundled overrides-darwin-arm64.txt. uv splits UV_OVERRIDE on whitespace, so a repo cloned under a path containing a space (e.g. /Users/me/Open Source/unsloth) truncates the value and every later uv call aborts with 'error: File not found: ' (the PyTorch install step in #6503). Copy the overrides file into a space-free temp dir and point uv at the copy when the path contains a space, mirroring the macOS/Linux handling already merged for the Python installer in #6534. The temp dir is removed in the exit trap, and the code falls back to the original path when no space-free temp dir is available, so the no-space and non-macOS paths are unchanged. Adds tests/sh/test_install_uv_override_space.sh, which extracts and runs the install.sh hardening block and checks the spaced, no-space, and spaced-TMPDIR fallback cases. * Installer: match all whitespace (not just spaces) in UV_OVERRIDE handling uv splits UV_OVERRIDE on any whitespace, so use the POSIX class *[[:space:]]* rather than a literal space in install.sh (catches tabs and newlines in the path too) and the matching test assertions. Use the portable awk bracket expression [$] instead of \$ in the extraction so the test runs the same under BSD awk (macOS) and GNU awk (Linux). Adds a tab-in-path case. * Installer: clear _UV_OVERRIDE_TMPDIR before the exit trap The exit trap rm -rf's _UV_OVERRIDE_TMPDIR. Initialize it to empty before registering the trap so an inherited environment value can never be removed; only a temp dir this script creates (Apple Silicon, spaced path) is cleaned. Adds a structural test asserting the init precedes the trap. * Run the install.sh UV_OVERRIDE space test in CI via a pytest wrapper The Shell installer tests job uses a fixed script list (not tests/run_all.sh), so the new shell test would not run on PRs. Add a pytest wrapper under tests/python/ that invokes it; the auto-discovered repo CPU test job collects tests/python/ and so executes the Apple Silicon spaced-path regression. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- install.sh | 23 ++++ .../python/test_install_uv_override_space.py | 31 +++++ tests/run_all.sh | 1 + tests/sh/test_install_uv_override_space.sh | 112 ++++++++++++++++++ 4 files changed, 167 insertions(+) create mode 100644 tests/python/test_install_uv_override_space.py create mode 100755 tests/sh/test_install_uv_override_space.sh diff --git a/install.sh b/install.sh index b3eaa61003..548e6f702a 100755 --- a/install.sh +++ b/install.sh @@ -447,8 +447,12 @@ _on_install_exit() { if [ "$_status" -ne 0 ]; then _restore_studio_venv_replacement fi + [ -n "${_UV_OVERRIDE_TMPDIR:-}" ] && rm -rf "$_UV_OVERRIDE_TMPDIR" 2>/dev/null || true exit "$_status" } +# Empty so an inherited value can never reach the trap's rm; only a temp dir +# this script creates below (Apple Silicon, spaced path) is ever removed. +_UV_OVERRIDE_TMPDIR="" trap _on_install_exit EXIT # ── Helper: download a URL to a file (supports curl and wget) ── @@ -1427,6 +1431,25 @@ fi if [ "$OS" = "macos" ] && [ "$_ARCH" = "arm64" ]; then _OVERRIDES_FILE="$(cd "$(dirname "$0" 2>/dev/null || echo ".")" && pwd)/studio/backend/requirements/single-env/overrides-darwin-arm64.txt" if [ -f "$_OVERRIDES_FILE" ]; then + # uv splits UV_OVERRIDE on whitespace, so a repo path with whitespace + # truncates it and aborts every later uv call (issue #6503). Hand uv a copy. + case "$_OVERRIDES_FILE" in + *[[:space:]]*) + _UV_OVERRIDE_TMPDIR=$(mktemp -d 2>/dev/null) || _UV_OVERRIDE_TMPDIR="" + case "$_UV_OVERRIDE_TMPDIR" in + "") ;; + *[[:space:]]*) rm -rf "$_UV_OVERRIDE_TMPDIR" 2>/dev/null || true; _UV_OVERRIDE_TMPDIR="" ;; + *) + if cp "$_OVERRIDES_FILE" "$_UV_OVERRIDE_TMPDIR/overrides-darwin-arm64.txt" 2>/dev/null; then + _OVERRIDES_FILE="$_UV_OVERRIDE_TMPDIR/overrides-darwin-arm64.txt" + else + rm -rf "$_UV_OVERRIDE_TMPDIR" 2>/dev/null || true + _UV_OVERRIDE_TMPDIR="" + fi + ;; + esac + ;; + esac export UV_OVERRIDE="$_OVERRIDES_FILE" fi fi diff --git a/tests/python/test_install_uv_override_space.py b/tests/python/test_install_uv_override_space.py new file mode 100644 index 0000000000..86d918b043 --- /dev/null +++ b/tests/python/test_install_uv_override_space.py @@ -0,0 +1,31 @@ +"""Run the install.sh UV_OVERRIDE space-safety shell test (issue #6503) under +pytest, so the auto-discovered CPU test job executes it. The dedicated +`Shell installer tests` CI job runs a fixed script list that this is not part +of, so without this wrapper the regression would only be covered locally via +tests/run_all.sh. +""" + +from __future__ import annotations + +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +SHELL_TEST = REPO_ROOT / "tests" / "sh" / "test_install_uv_override_space.sh" + + +@pytest.mark.skipif(sys.platform == "win32", reason = "POSIX shell installer test") +@pytest.mark.skipif(shutil.which("bash") is None, reason = "bash not available") +def test_install_uv_override_space_shell(): + assert SHELL_TEST.is_file(), f"missing shell test: {SHELL_TEST}" + proc = subprocess.run( + ["bash", str(SHELL_TEST)], + capture_output = True, + text = True, + ) + assert proc.returncode == 0, proc.stdout + proc.stderr + assert "ALL PASSED" in proc.stdout, proc.stdout + proc.stderr diff --git a/tests/run_all.sh b/tests/run_all.sh index 18182d9db7..d03f4c4d4f 100755 --- a/tests/run_all.sh +++ b/tests/run_all.sh @@ -15,6 +15,7 @@ sh "$TESTS_DIR/sh/test_resolve_cuda_archs.sh" sh "$TESTS_DIR/sh/test_strixhalo_wsl_reroute.sh" sh "$TESTS_DIR/sh/test_uninstall_shared_icon.sh" sh "$TESTS_DIR/sh/test_torch_flavor.sh" +sh "$TESTS_DIR/sh/test_install_uv_override_space.sh" echo "" echo "=== Python tests ===" diff --git a/tests/sh/test_install_uv_override_space.sh b/tests/sh/test_install_uv_override_space.sh new file mode 100755 index 0000000000..07ef36295a --- /dev/null +++ b/tests/sh/test_install_uv_override_space.sh @@ -0,0 +1,112 @@ +#!/bin/bash +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 +# uv splits UV_OVERRIDE on whitespace, so a repo cloned under a path with a space +# truncates it and aborts every later uv call (issue #6503). install.sh must hand +# uv a space-free copy. Exercises the real install.sh hardening block. +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +INSTALL_SH="$SCRIPT_DIR/../../install.sh" +PASS=0 +FAIL=0 + +ok() { echo " PASS: $1"; PASS=$((PASS + 1)); } +bad() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); } + +# Extract the UV_OVERRIDE hardening block (outer case ... esac plus the export) +# and run it directly, so the test tracks install.sh rather than a copy of it. +BLOCK=$(awk ' + /case "[$]_OVERRIDES_FILE" in/ { grab = 1 } + grab { print } + grab && /export UV_OVERRIDE="[$]_OVERRIDES_FILE"/ { exit } +' "$INSTALL_SH") +if ! printf '%s' "$BLOCK" | grep -q 'export UV_OVERRIDE'; then + echo " FAIL: could not extract UV_OVERRIDE block from install.sh" + exit 1 +fi + +run_block() { + _OVERRIDES_FILE="$1" + _UV_OVERRIDE_TMPDIR="" + unset UV_OVERRIDE + eval "$BLOCK" +} + +echo "=== test_install_uv_override_space ===" + +# 1. Spaced path -> space-free copy with identical contents, temp dir tracked. +WORK=$(mktemp -d) +mkdir -p "$WORK/Open Source" +SRC="$WORK/Open Source/overrides-darwin-arm64.txt" +printf 'transformers>=4.57.6\n' > "$SRC" +run_block "$SRC" +case "$UV_OVERRIDE" in + *[[:space:]]*) bad "spaced path: UV_OVERRIDE still contains whitespace ($UV_OVERRIDE)" ;; + *) ok "spaced path: UV_OVERRIDE is whitespace-free" ;; +esac +[ "$UV_OVERRIDE" != "$SRC" ] && ok "spaced path: points at a copy" || bad "spaced path: not copied" +[ "$(cat "$UV_OVERRIDE" 2>/dev/null)" = "transformers>=4.57.6" ] \ + && ok "spaced path: copy contents identical" || bad "spaced path: contents differ" +{ [ -n "$_UV_OVERRIDE_TMPDIR" ] && [ -d "$_UV_OVERRIDE_TMPDIR" ]; } \ + && ok "spaced path: temp dir tracked for cleanup" || bad "spaced path: temp dir not tracked" +# The exit-trap cleanup (_on_install_exit) must then remove it. +[ -n "$_UV_OVERRIDE_TMPDIR" ] && rm -rf "$_UV_OVERRIDE_TMPDIR" 2>/dev/null || true +[ ! -d "$_UV_OVERRIDE_TMPDIR" ] && ok "spaced path: temp dir removable" || bad "spaced path: temp dir lingers" +rm -rf "$WORK" + +# 2. No-space path -> passthrough, no temp dir. +PLAIN=$(mktemp -d) +PSRC="$PLAIN/overrides-darwin-arm64.txt" +printf 'transformers>=4.57.6\n' > "$PSRC" +run_block "$PSRC" +[ "$UV_OVERRIDE" = "$PSRC" ] && ok "no-space path: UV_OVERRIDE unchanged" || bad "no-space path: changed ($UV_OVERRIDE)" +[ -z "$_UV_OVERRIDE_TMPDIR" ] && ok "no-space path: no temp dir created" || bad "no-space path: temp dir created" +rm -rf "$PLAIN" + +# 3. TMPDIR itself contains a space -> fall back to the original path, no leak. +WORK2=$(mktemp -d) +mkdir -p "$WORK2/Open Source" "$WORK2/tmp dir" +SRC2="$WORK2/Open Source/overrides-darwin-arm64.txt" +printf 'transformers>=4.57.6\n' > "$SRC2" +RES=$( TMPDIR="$WORK2/tmp dir"; export TMPDIR; run_block "$SRC2" + printf 'UV_OVERRIDE=%s\nTMPDIR_VAR=%s\n' "$UV_OVERRIDE" "$_UV_OVERRIDE_TMPDIR" ) +echo "$RES" | grep -qx "UV_OVERRIDE=$SRC2" \ + && ok "spaced TMPDIR: falls back to original path" || bad "spaced TMPDIR: did not fall back ($RES)" +echo "$RES" | grep -qx "TMPDIR_VAR=" \ + && ok "spaced TMPDIR: no temp dir tracked" || bad "spaced TMPDIR: temp dir tracked" +# mktemp may have created a dir under the spaced TMPDIR; it must not be leaked. +_leftover=$(find "$WORK2/tmp dir" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | head -n1) +[ -z "$_leftover" ] && ok "spaced TMPDIR: no leaked temp dir" || bad "spaced TMPDIR: leaked $_leftover" +rm -rf "$WORK2" + +# 4. A tab in the path is whitespace uv also splits on -> copied like a space. +WORK3=$(mktemp -d) +TABDIR=$(printf 'Open\tSource') +mkdir -p "$WORK3/$TABDIR" +SRC3="$WORK3/$TABDIR/overrides-darwin-arm64.txt" +printf 'transformers>=4.57.6\n' > "$SRC3" +run_block "$SRC3" +case "$UV_OVERRIDE" in + *[[:space:]]*) bad "tab path: UV_OVERRIDE still contains whitespace" ;; + *) ok "tab path: UV_OVERRIDE is whitespace-free" ;; +esac +[ -n "$_UV_OVERRIDE_TMPDIR" ] && rm -rf "$_UV_OVERRIDE_TMPDIR" 2>/dev/null || true +rm -rf "$WORK3" + +# 5. install.sh must clear _UV_OVERRIDE_TMPDIR before registering the exit trap, +# so an inherited value can never reach the trap's rm -rf. +_init_line=$(grep -n '^_UV_OVERRIDE_TMPDIR=""' "$INSTALL_SH" | head -n1 | cut -d: -f1) +_trap_line=$(grep -n '^trap _on_install_exit EXIT' "$INSTALL_SH" | head -n1 | cut -d: -f1) +{ [ -n "$_init_line" ] && [ -n "$_trap_line" ] && [ "$_init_line" -lt "$_trap_line" ]; } \ + && ok "init: _UV_OVERRIDE_TMPDIR cleared before exit trap" \ + || bad "init: _UV_OVERRIDE_TMPDIR not cleared before exit trap (init=$_init_line trap=$_trap_line)" + +echo "" +echo " PASS: $PASS" +echo " FAIL: $FAIL" +if [ "$FAIL" -gt 0 ]; then + echo "FAILED" + exit 1 +fi +echo "ALL PASSED" From e25e7895a5024b3545d22b334c00b468b0f28141 Mon Sep 17 00:00:00 2001 From: Wasim Yousef Said Date: Thu, 25 Jun 2026 02:56:25 +0200 Subject: [PATCH 059/192] Polish Studio desktop chrome (#6332) * Polish Studio desktop chrome * Fix desktop chrome chat header overlap * Blend desktop titlebar with sidebar * Refine desktop chrome alignment * Fix desktop chrome review items * Reserve mac sidebar chrome space * Fix mac chrome review items * Polish macOS desktop chrome * Align macOS desktop chrome controls * Lower macOS traffic lights * Remove mac sidebar logo from chrome row * Match Tauri update banner styling * Update Tauri updater public key * Fix Tauri startup screen spacing * Work around AppImage WebKitGTK blank screen * Mark Linux AppImage as experimental * Address true desktop chrome review issues * Fix remaining desktop chrome review issues * Fix desktop titlebar inset review issues * Refresh desktop platform after backend auth --- .github/workflows/release-desktop.yml | 15 +- studio/frontend/src/app/provider.tsx | 171 ++++++++++--- studio/frontend/src/app/routes/__root.tsx | 4 +- .../frontend/src/components/app-sidebar.tsx | 242 +++++++++++------- .../src/components/assistant-ui/thread.tsx | 4 +- studio/frontend/src/components/navbar.tsx | 13 +- .../src/components/tauri/startup-screen.tsx | 6 +- .../src/components/tauri/update-banner.tsx | 127 +++++++-- .../src/components/tauri/update-screen.tsx | 4 +- .../src/components/tauri/window-titlebar.tsx | 183 +++++++++---- .../frontend/src/features/chat/chat-page.tsx | 53 ++-- .../src/features/chat/chat-settings-sheet.tsx | 9 +- .../src/features/settings/tabs/about-tab.tsx | 46 ++-- studio/src-tauri/icons/128x128.png | Bin 9194 -> 10181 bytes studio/src-tauri/icons/32x32.png | Bin 1930 -> 2065 bytes studio/src-tauri/icons/icon.icns | Bin 263568 -> 311926 bytes studio/src-tauri/icons/icon.ico | Bin 34589 -> 38034 bytes studio/src-tauri/icons/icon.png | Bin 46007 -> 42705 bytes studio/src-tauri/src/main.rs | 24 ++ studio/src-tauri/tauri.conf.json | 12 +- 20 files changed, 649 insertions(+), 264 deletions(-) diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index e747605322..884ff02d11 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -438,6 +438,12 @@ jobs: if (/\brpm\b|\.rpm/i.test(body)) { throw new Error('Desktop release body must not advertise RPM packages'); } + if (/AppImage.*universal|universal.*AppImage/i.test(body)) { + throw new Error('Desktop release body must not advertise AppImage as universal'); + } + if (!/AppImage.*experimental/i.test(body)) { + throw new Error('Desktop release body must mark AppImage as experimental'); + } } JS @@ -580,9 +586,10 @@ jobs: **macOS**: Download the Apple Silicon `.dmg`. **Windows**: Download the `-setup.exe` installer. - **Linux**: Download `.deb` (Ubuntu/Debian) or `.AppImage` (universal). + **Linux**: Download `.deb` for Ubuntu/Debian. `.AppImage` is experimental. > Linux in-app updates are AppImage-oriented. Package installs should update by downloading a new package. + > Linux AppImage can show a blank window on some Tauri/WebKitGTK + Wayland/Mesa stacks; use `.deb` when available. > Linux AppImage on Ubuntu 24.04+ may require: `sudo apt install libfuse2t64` > First-run system dependency elevation is supported on Ubuntu/Debian. Other Linux distributions should install system packages manually. releaseDraft: ${{ inputs.draft }} @@ -611,9 +618,10 @@ jobs: **macOS**: Download the Apple Silicon `.dmg`. **Windows**: Download the `-setup.exe` installer. - **Linux**: Download `.deb` (Ubuntu/Debian) or `.AppImage` (universal). + **Linux**: Download `.deb` for Ubuntu/Debian. `.AppImage` is experimental. > Linux in-app updates are AppImage-oriented. Package installs should update by downloading a new package. + > Linux AppImage can show a blank window on some Tauri/WebKitGTK + Wayland/Mesa stacks; use `.deb` when available. > Linux AppImage on Ubuntu 24.04+ may require: `sudo apt install libfuse2t64` > First-run system dependency elevation is supported on Ubuntu/Debian. Other Linux distributions should install system packages manually. releaseDraft: ${{ inputs.draft }} @@ -643,9 +651,10 @@ jobs: **macOS**: Download the Apple Silicon `.dmg`. **Windows**: Download the `-setup.exe` installer. - **Linux**: Download `.deb` (Ubuntu/Debian) or `.AppImage` (universal). + **Linux**: Download `.deb` for Ubuntu/Debian. `.AppImage` is experimental. > Linux in-app updates are AppImage-oriented. Package installs should update by downloading a new package. + > Linux AppImage can show a blank window on some Tauri/WebKitGTK + Wayland/Mesa stacks; use `.deb` when available. > Linux AppImage on Ubuntu 24.04+ may require: `sudo apt install libfuse2t64` > First-run system dependency elevation is supported on Ubuntu/Debian. Other Linux distributions should install system packages manually. releaseDraft: ${{ inputs.draft }} diff --git a/studio/frontend/src/app/provider.tsx b/studio/frontend/src/app/provider.tsx index 802f22e21e..914abbbf1d 100644 --- a/studio/frontend/src/app/provider.tsx +++ b/studio/frontend/src/app/provider.tsx @@ -6,6 +6,7 @@ import { UpdateBanner } from "@/components/tauri/update-banner"; import { UpdateScreen } from "@/components/tauri/update-screen"; import { WindowTitlebar, + shouldUseNativeMacWindowTitlebar, shouldUseCustomWindowTitlebar, } from "@/components/tauri/window-titlebar"; import { Toaster } from "@/components/ui/sonner"; @@ -18,9 +19,16 @@ import { NativeIntentDrain } from "@/features/native-intents/native-intent-drain import { useTauriBackend, type BackendStatus } from "@/hooks/use-tauri-backend"; import { useTauriUpdate } from "@/hooks/use-tauri-update"; import { isTauri } from "@/lib/api-base"; +import { fetchDeviceType } from "@/config/env"; import { useRouterState } from "@tanstack/react-router"; import { ThemeProvider } from "next-themes"; -import { useEffect, useRef, useState, type ReactNode } from "react"; +import { + useEffect, + useRef, + useState, + type CSSProperties, + type ReactNode, +} from "react"; interface AppProviderProps { children: ReactNode; @@ -31,18 +39,43 @@ type WindowLayoutGuard = () => boolean; const MIN_WINDOW_WIDTH = 900; const MIN_WINDOW_HEIGHT = 600; +const SETUP_WINDOW_WIDTH = 760; +const SETUP_WINDOW_HEIGHT = 560; async function showSetupWindow(isCurrent: WindowLayoutGuard): Promise { - const { getCurrentWindow } = await import("@tauri-apps/api/window"); + const { getCurrentWindow, LogicalSize } = await import("@tauri-apps/api/window"); if (!isCurrent()) return; const win = getCurrentWindow(); + await win.setResizable(false); + if (!isCurrent()) return; + await win.setSize(new LogicalSize(SETUP_WINDOW_WIDTH, SETUP_WINDOW_HEIGHT)); if (!isCurrent()) return; await win.center(); if (!isCurrent()) return; await win.show(); } +async function enforceMinimumWindowSize( + win: Awaited>, + LogicalSize: typeof import("@tauri-apps/api/window")["LogicalSize"], + isCurrent: WindowLayoutGuard, +): Promise { + const [innerSize, scaleFactor] = await Promise.all([ + win.innerSize(), + win.scaleFactor(), + ]); + if (!isCurrent()) return; + + const logicalWidth = Math.round(innerSize.width / scaleFactor); + const logicalHeight = Math.round(innerSize.height / scaleFactor); + const nextWidth = Math.max(logicalWidth, MIN_WINDOW_WIDTH); + const nextHeight = Math.max(logicalHeight, MIN_WINDOW_HEIGHT); + if (nextWidth !== logicalWidth || nextHeight !== logicalHeight) { + await win.setSize(new LogicalSize(nextWidth, nextHeight)); + } +} + async function applyAppWindowLayout(isCurrent: WindowLayoutGuard): Promise { const { getCurrentWindow, currentMonitor, LogicalSize } = await import("@tauri-apps/api/window"); const { invoke } = await import("@tauri-apps/api/core"); @@ -91,6 +124,8 @@ async function applyAppWindowLayout(isCurrent: WindowLayoutGuard): Promise // Apply constraints after restore/show: doing so before plugin restore can emit // a Resized event and overwrite the plugin's cached saved size. await win.setSizeConstraints({ minWidth: MIN_WINDOW_WIDTH, minHeight: MIN_WINDOW_HEIGHT }); + if (!isCurrent()) return; + await enforceMinimumWindowSize(win, LogicalSize, isCurrent); } async function showWindowFallback(): Promise { @@ -123,7 +158,13 @@ function getTauriWindowMode( } } -function TauriUpdateLayer({ isExternalServer }: { isExternalServer: boolean }) { +function TauriUpdateLayer({ + isExternalServer, + children, +}: { + isExternalServer: boolean; + children?: ReactNode; +}) { const update = useTauriUpdate(isExternalServer); const isUpdating = update.status === "updating-backend" || @@ -146,18 +187,22 @@ function TauriUpdateLayer({ isExternalServer }: { isExternalServer: boolean }) { } return ( - +
+ + {children} +
); } @@ -175,6 +220,35 @@ const WEB_UPDATE_HIDDEN_ROUTES = new Set([ "/signup", ]); +const MAC_NATIVE_CHROME_STYLE = { + "--studio-titlebar-height": "0px", + "--studio-mac-titlebar-height": "34px", + "--studio-mac-traffic-light-inset": "78px", + "--studio-startup-top-inset": "58px", + "--studio-content-top-inset": "0px", + "--studio-non-chat-content-top-inset": "34px", + "--studio-hidden-route-top-inset": "34px", + "--studio-chat-header-height": "44px", + "--studio-chat-header-padding-top": "8px", + "--studio-chat-control-height": "33px", + "--studio-chat-header-right-inset": "0px", +} as CSSProperties; + +const CUSTOM_CHROME_STYLE = { + "--studio-titlebar-height": "0px", + "--studio-custom-titlebar-height": "34px", + "--studio-sidebar-expanded-width": "17.5rem", + "--studio-sidebar-collapsed-width": "3rem", + "--studio-startup-top-inset": "42px", + "--studio-content-top-inset": "34px", + "--studio-hidden-route-top-inset": "34px", + "--studio-chat-header-height": "48px", + "--studio-chat-header-padding-top": "9px", + "--studio-chat-control-height": "33px", + "--studio-chat-header-right-inset": "0px", + "--studio-window-control-inset": "112px", +} as CSSProperties; + function TauriWrapper({ children }: { children: ReactNode }) { const pathname = useRouterState({ select: (s) => s.location.pathname }); const { @@ -254,6 +328,11 @@ function TauriWrapper({ children }: { children: ReactNode }) { return () => { disposed = true; }; }, [status, desktopAuthRetry]); + useEffect(() => { + if (!isTauri || status !== "running" || !desktopAuthReady) return; + void fetchDeviceType({ force: true }).catch(() => undefined); + }, [status, desktopAuthReady]); + if (!isTauri) { return ( <> @@ -281,10 +360,19 @@ function TauriWrapper({ children }: { children: ReactNode }) { status === "running" && !desktopAuthReady ? "Signing in to desktop session..." : progressDetail; + const usesCustomTitlebar = shouldUseCustomWindowTitlebar(); + const usesNativeMacTitlebar = shouldUseNativeMacWindowTitlebar(); + const hidesTitlebarSidebar = HIDDEN_TITLEBAR_SIDEBAR_ROUTES.has(pathname); const content = showApp ? ( <> - + + + + {children} @@ -305,39 +393,48 @@ function TauriWrapper({ children }: { children: ReactNode }) { /> ); - if (!shouldUseCustomWindowTitlebar()) { + if (!usesCustomTitlebar) { // macOS desktop uses the native titlebar and returns here before the // custom-titlebar branch, so mount the updater banner on this path too. - return ( - <> - {content} -
- - {showApp ? : null} + if (usesNativeMacTitlebar) { + return ( +
+ {(!showApp || hidesTitlebarSidebar) ? ( + - + ); + } + + return ( + <>{content} ); } const showSidebarSurface = - showApp && !HIDDEN_TITLEBAR_SIDEBAR_ROUTES.has(pathname); + showApp && !hidesTitlebarSidebar; return ( -
+
-
+
{content}
-
- - {showApp ? : null} -
); } diff --git a/studio/frontend/src/app/routes/__root.tsx b/studio/frontend/src/app/routes/__root.tsx index 77ba5788db..e5fa6f0191 100644 --- a/studio/frontend/src/app/routes/__root.tsx +++ b/studio/frontend/src/app/routes/__root.tsx @@ -219,7 +219,7 @@ function RootLayout() { {hideNavbar ? ( -
+
}> @@ -235,7 +235,7 @@ function RootLayout() {
{/* Stays mounted across navigation so an in-flight generation is not cancelled when leaving /chat; hidden (not unmounted) off-route. diff --git a/studio/frontend/src/components/app-sidebar.tsx b/studio/frontend/src/components/app-sidebar.tsx index f563f9ae59..06f2701a16 100644 --- a/studio/frontend/src/components/app-sidebar.tsx +++ b/studio/frontend/src/components/app-sidebar.tsx @@ -44,7 +44,12 @@ import { Button } from "@/components/ui/button"; import { Spinner } from "@/components/ui/spinner"; import { Switch } from "@/components/ui/switch"; import { useAnimatedThemeToggle } from "@/components/ui/animated-theme-toggler"; +import { + shouldUseCustomWindowTitlebar, + shouldUseNativeMacWindowTitlebar, +} from "@/components/tauri/window-titlebar"; import { cn } from "@/lib/utils"; +import { isTauri } from "@/lib/api-base"; import { useWebUpdateCheck } from "@/hooks/use-web-update-check"; import { Archive03Icon, @@ -272,6 +277,8 @@ function devForceUpdateCard(): boolean { export function AppSidebar() { const t = useT(); const { isDark, toggleTheme, anchorRef } = useAnimatedThemeToggle(); + const [usesCustomTitlebar] = useState(shouldUseCustomWindowTitlebar); + const [usesNativeMacTitlebar] = useState(shouldUseNativeMacWindowTitlebar); const { pathname, search } = useRouterState({ select: (s) => ({ pathname: s.location.pathname, @@ -458,6 +465,10 @@ export function AppSidebar() { isStudioRoute, ]); + const chatDisabled = trainingInProgress; + const showSidebarBrand = !usesCustomTitlebar; + const showCompactMacBrand = showSidebarBrand && usesNativeMacTitlebar; + function chatSearchForProject(projectId: string | null) { if (projectId) { return { project: projectId }; @@ -983,81 +994,118 @@ export function AppSidebar() { variant="sidebar" className="font-heading group-data-[collapsible=icon]:[&_[data-sidebar=sidebar]]:bg-white dark:group-data-[collapsible=icon]:[&_[data-sidebar=sidebar]]:bg-background" > - - {/* Expanded: compact logo + close toggle */} -
- { - event.preventDefault(); - openNewChat(null); - }} - className="flex items-center gap-[6px] select-none" - aria-label={t("shell.aria.home")} - > - Unsloth - - unsloth - - - {t("shell.beta")} - - - {!isMobile && ( - - - - - - {t("shell.aria.closeSidebar")} - - - )} -
- - {/* Collapsed: panel icon doubles as expand trigger */} - {!isMobile && ( -
- - - - - - {t("shell.aria.openSidebar")} - - -
+ Unsloth + + unsloth + + + {t("shell.beta")} + + + )} + {!isMobile && ( + + + + + + {t("shell.aria.closeSidebar")} + + + )} +
+ {!isMobile && ( +
+ + + + + + {t("shell.aria.openSidebar")} + + +
+ )} + )} {/* Uniform pl-1.5 pr-2 keeps every hover pill the same width, inset from the edge. */} - + {t("common.help")} - { - // Best-effort server revocation; ignore network errors so - // the local clear still runs and the user lands on /login. - try { - await logout(); - } catch { - clearAuthTokens(); - } - void navigate({ to: "/login" }); - }} - > - - {t("shell.navigation.logOut")} - - setShutdownOpen(true)}> - - {t("common.shutdown")} - + {!isTauri && ( + { + // Best-effort server revocation; ignore network errors so + // the local clear still runs and the user lands on /login. + try { + await logout(); + } catch { + clearAuthTokens(); + } + void navigate({ to: "/login" }); + }} + > + + {t("shell.navigation.logOut")} + + )} + {!isTauri && ( + setShutdownOpen(true)}> + + {t("common.shutdown")} + + )} @@ -1571,11 +1623,13 @@ export function AppSidebar() { - + {!isTauri && ( + + )} { diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx index bad9a6b7f3..a05910c29f 100644 --- a/studio/frontend/src/components/assistant-ui/thread.tsx +++ b/studio/frontend/src/components/assistant-ui/thread.tsx @@ -968,7 +968,9 @@ export const Thread: FC<{ scrollToBottomOnThreadSwitch={false} className={cn( "aui-thread-viewport aui-stream-viewport relative flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-x-auto overflow-y-auto scroll-smooth px-5", - hideComposer ? "pt-4" : "pt-[48px]", + hideComposer + ? "pt-4" + : "pt-[calc(var(--studio-content-top-inset,0px)+48px)]", )} > {!hideWelcome && ( diff --git a/studio/frontend/src/components/navbar.tsx b/studio/frontend/src/components/navbar.tsx index 716c9d791f..44387f2480 100644 --- a/studio/frontend/src/components/navbar.tsx +++ b/studio/frontend/src/components/navbar.tsx @@ -1,13 +1,24 @@ // 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 { shouldUseNativeMacWindowTitlebar } from "@/components/tauri/window-titlebar"; import { SidebarTrigger, useSidebar } from "@/components/ui/sidebar"; +import { useState } from "react"; export function Navbar() { const { isMobile } = useSidebar(); + const [usesNativeMacTitlebar] = useState(shouldUseNativeMacWindowTitlebar); if (!isMobile) { return ( -
+
+ {usesNativeMacTitlebar && ( +
); } return ( diff --git a/studio/frontend/src/components/tauri/startup-screen.tsx b/studio/frontend/src/components/tauri/startup-screen.tsx index fd67a8a841..678051b36b 100644 --- a/studio/frontend/src/components/tauri/startup-screen.tsx +++ b/studio/frontend/src/components/tauri/startup-screen.tsx @@ -433,12 +433,12 @@ export function StartupScreen({ } return ( -
-
+
+
void; onDismiss: () => void; onCopyDiagnostics: () => Promise; @@ -27,6 +31,11 @@ interface UpdateBannerProps { const EASE_OUT_QUART: [number, number, number, number] = [0.165, 0.84, 0.44, 1]; +function formatVersion(version: string | null | undefined): string { + if (!version) return ""; + return version.startsWith("v") ? version : `v${version}`; +} + export function UpdateBanner({ status, info, @@ -35,6 +44,7 @@ export function UpdateBanner({ isExternalServer = false, updatePolicyMode, manualReleaseUrl, + positioned = true, onInstall, onDismiss, onCopyDiagnostics, @@ -49,6 +59,9 @@ export function UpdateBanner({ const installDisabled = isManualLinuxPackage ? manualReleaseUrl === null : isExternalServer; + const currentVersion = formatVersion(info?.currentVersion); + const latestVersion = formatVersion(info?.version); + const Icon = showFailure ? CircleAlert : Download; async function handleCopyDiagnostics() { setCopying(true); @@ -59,7 +72,10 @@ export function UpdateBanner({ setManualMessage(null); } else { setManualReport(result.report); - setManualMessage(result.error ?? "Clipboard copy failed. Select and copy the diagnostics below."); + setManualMessage( + result.error ?? + "Clipboard copy failed. Select and copy the diagnostics below.", + ); } } catch (error) { setManualReport(null); @@ -73,30 +89,60 @@ export function UpdateBanner({ {show && ( -
+
-
- 🦥 -
-

- {showFailure ? "App update failed" : `New version: v${info?.version}`} +

+