unsloth/studio/backend/routes/models.py
Daniel Han 0acd1c7eec
studio: improve onboarding UX, tooltips, and training defaults (#4355)
* studio: improve onboarding UX, tooltips, and training defaults

- Change splash text to "Train and run LLMs locally"
- Add "Chat Only" card with BubbleChatIcon to skip directly to chat
- Add Skip/Skip to Chat buttons in sidebar and footer
- Back button on step 1 returns to splash screen instead of being disabled
- Change "Watch video guide" to "Get started with our guide" with new URL
- Update intro text to mention all model types + chat
- Make all tooltips clickable (in addition to hover) via React context
- Strip surrounding quotes from pasted HF tokens
- Rename "Eval Split" to "Evaluation Split"
- Add SparklesIcon to "Auto Detect" format option
- Change step 4 heading to "Choose your training parameters"
- Default max_steps to 60
- Learning rate displayed in scientific notation with +/- stepper
- Context length options capped by model's max_position_embeddings (via AutoConfig)
- Fix "QLORA"/"LORA" to "QLoRA"/"LoRA" in summary step
- Backend: add max_position_embeddings to model config endpoint

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* compare for 2 diff models

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* resolving gemini comments

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* studio: disable thinking for Qwen3.5 <9B and always for AI Assist

- Change Qwen3.5 thinking threshold from <=2B to <9B (0.8B, 2B, 4B
  all disable thinking by default; 9B+ enables it)
- Always pass enable_thinking=False in AI Assist helper calls
  (_run_with_helper and _generate_with_backend) regardless of chat
  thinking settings

* studio: address PR review comments

- Extract _get_max_position_embeddings helper to DRY config extraction
- Fix "Skip to Chat" to navigate to /chat on step 1 (was /studio)

* fix: comment out debug print statements

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* studio: skip Shiki highlighting for incomplete SVG code fences

While streaming SVG content, the syntax highlighter (Shiki) re-parses
the entire growing SVG on every token, blocking the main thread and
freezing the code area until the fence closes. Show a plain-text
preview for incomplete SVG fences instead, similar to how Mermaid
diagrams show a placeholder while streaming.

* studio: fix default top_k from 50/40 to 20 for chat inference

Per Qwen3.5 docs (unsloth.ai/docs/models/qwen3.5), top_k should be 20
for both thinking and non-thinking modes. The model-specific config in
inference_defaults.json already had top_k=20 for Qwen3.5, but the
generic fallback defaults were wrong:
- Frontend DEFAULT_INFERENCE_PARAMS.topK: 50 -> 20
- Backend generate_chat_completion top_k: 40 -> 20
- Backend generate_chat_completion_with_tools top_k: 40 -> 20
- Frontend title generation top_k: 40 -> 20

* studio: set universal inference defaults for unknown models

Default params for any model without specific config:
  temperature=0.6, top_p=0.95, top_k=20, min_p=0.01,
  presence_penalty=0.0, repetition_penalty=1.0

Models with entries in inference_defaults.json (Qwen3.5, Gemma-3,
Llama, etc.) override these with their recommended values.

Updated in: frontend DEFAULT_INFERENCE_PARAMS, backend Pydantic
request models, and backend generate_chat_completion defaults.

* studio: only trust_remote_code for unsloth/ models in AutoConfig

Only set trust_remote_code=True when the model name starts with
"unsloth/". All other models default to False for safety.

* studio: move Generating spinner above the composer

The "Generating" spinner was below the send message bar, causing
the bar to jump up and down. Move it above the composer in both
the regular thread view and the welcome/empty view.

* studio: adjust toast close button position away from edge

Move the X close button on toasts (like "Starting model...") from
top-1.5 to top-3 and add right-3, giving more breathing room from
the top-right corner.

* studio: make Think button smaller with tighter icon-text gap

Reduce gap from 1.5 to 0.5, padding from px-2.5/py-1 to px-2/py-0.5,
and icon from size-3.5 to size-3.

* studio: multiple onboarding and chat UX improvements

- Move Generating spinner above composer (fixes jumping send bar)
- Make Think button smaller with tighter icon-text gap
- Chat card now inside grid (same size as Audio/Embeddings cards)
- Rename "Chat Only" to "Chat"
- Chat card requires Continue to proceed (no auto-advance)
- Continue on Chat selection skips onboarding and goes to /chat
- Tooltip (i) click on Chat card doesn't trigger navigation
- Step 1 footer Back button goes back to splash (label is "Back")
- Splash "Skip Onboarding" renamed to "Skip to Chat", navigates to /chat
- Toast close button moved away from edge

* studio: align Skip to Chat button, add Skip to footer

- Sidebar "Skip to Chat" now uses primary (green) Button style with
  arrow icon, full width, aligned like step items. Shows on all steps.
- Footer: added "Skip" outline button next to Continue that goes
  directly to /studio with progress saved (markOnboardingDone)

* studio: change default max steps from 30 to 60 in toggle hook

The DEFAULT_MAX_STEPS in use-max-steps-epochs-toggle.ts was still 30,
used as fallback when toggling from epochs back to max steps.

* studio: extend context length options to 262K

CONTEXT_LENGTHS now includes 65536, 131072, 262144 in addition to
the existing 512-32768 range. The onboarding step filters these by
the model's max_position_embeddings (e.g. Nemotron-3-Nano-4B has
262144), showing powers of 2 up to the model's maximum.

* studio: auto-select LoRA vs QLoRA based on model size and GPU memory

After selecting a model in onboarding, detect the total model weight
file size from HF Hub (safetensors/bin files). Then estimate memory
needed: model_size_gb * 1.5 * context_scale, where context_scale is:
  - <=8192 tokens: 1.0x
  - >8192 tokens: 1.7x
  - >=16384 tokens: 2.0x
  - >=32768 tokens: 4.0x

If the estimate fits in free GPU VRAM, default to LoRA (16-bit).
Otherwise default to QLoRA (4-bit).

Backend changes:
- Add model_size_bytes to ModelDetails (models.py)
- Add _get_model_size_bytes() using HfApi.repo_info (routes/models.py)
- Add vram_free_gb to get_gpu_summary (hardware.py)

Frontend changes:
- Add autoSelectTrainingMethod() in training-config-store.ts
- Called after model defaults are loaded
- Add model_size_bytes to ModelConfigResponse type
- Add vramFreeGb to HardwareInfo hook

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* studio: rename "Importing ML libraries..." to "Importing Unsloth..."

* studio: show model/dataset in training status, fix LoRA/QLoRA casing

- Training status now shows 'Training "model_name"' and 'Dataset = ...'
  instead of generic "Starting training..."
- Fix Studio progress section to show QLoRA/LoRA instead of QLORA/LORA

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* studio: rename 'Skip to Chat' to 'Skip Onboarding' on splash screen

* studio: add presence_penalty support for chat inference

Add presence_penalty as a parameter across the full stack:
- Backend: llama_cpp.py generate_chat_completion/with_tools, Pydantic
  models (inference.py), routes/inference.py pass-through
- Frontend: InferenceParams type, DEFAULT_INFERENCE_PARAMS (0.0),
  chat-adapter.ts payload, chat-settings-sheet.tsx slider (0-2),
  model defaults loading from inference_defaults.json
- Set Qwen3.5 default presence_penalty to 1.5 per official docs
- Default for unknown models is 0.0 (off)

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* studio: fix Chat card deselecting Text and aligning with other cards

* studio: fix presence_penalty not loading from inference defaults

The inference_config.py load_inference_config() was not including
presence_penalty in the returned config dict, so the Qwen3.5
default of 1.5 from inference_defaults.json never reached the
frontend. Added it to the config builder.

* studio: add delete button for cached models in model selector

Add trash icon on each downloaded model row (GGUF and safetensors) with
confirmation dialog. Backend DELETE /api/models/delete-cached endpoint
uses huggingface_hub scan_cache_dir + delete_revisions to cleanly remove
cached repos, refusing if the model is currently loaded.

* studio: restore inference defaults, reasoning, and tools on page refresh

On page refresh with a model already loaded, the frontend was not
re-applying model-specific inference defaults (presence_penalty,
temperature, etc.) or restoring reasoning/tools support flags.

Backend: Add inference config, supports_reasoning, supports_tools,
and context_length to InferenceStatusResponse.

Frontend: In the refresh callback, when an active model is detected,
apply mergeRecommendedInference and restore reasoning/tools flags
with proper Qwen3.5 size-based defaults.

* studio: fix delete dialog closing before async completes

Prevent AlertDialogAction's default close behavior with
e.preventDefault() so the dialog stays open during deletion.
Also block onOpenChange dismiss while deleting is in progress.

* fix: add Dict and Any imports to inference models

* studio: fix Qwen3.5 reasoning threshold in frontend load path

The frontend loadModel handler had the old threshold (<=2) for
disabling reasoning on small Qwen3.5 models. Changed to <9 to
match the backend. This was causing 4B to not properly disable
thinking by default when auto-loaded.

* studio: move GGUF delete to per-variant level

For GGUF repos, the trash icon now appears on each downloaded variant
row inside the quantization expander instead of on the repo-level row.
Backend accepts optional variant param to delete specific GGUF files
(blob + symlink) rather than the entire repo cache.

* studio: restore ggufContextLength on page refresh

The Max Tokens slider was capped at 32768 on page refresh because
ggufContextLength was not restored from the status response.
Now set it from statusRes.context_length on reconnect.

* fix: remove <think> from Qwen3.5 response template marker

The train-on-responses-only feature uses template markers to find
where the assistant response starts. The Qwen3.5 response marker
included '<think>\n' which is only present when thinking mode is
enabled. With thinking disabled (default for <9B), the marker
never matched, causing 100% of samples to be dropped.

Changed response marker from '<|im_start|>assistant\n<think>\n'
to '<|im_start|>assistant\n' which works regardless of thinking mode.

* studio: fix sloth ASCII art alignment in training overlay

* fix: correct sloth ASCII art alignment to match Unsloth banner

* studio: add Python and terminal tool calling to chat

Register python and terminal tools alongside web search. Python
executor validates imports (stdlib only) via unsloth_zoo
rl_environments, runs code in a subprocess sandbox with 5-min
timeout and cancel support. Terminal executor blocks dangerous
commands (rm, sudo, etc.) and runs in a temp directory.

Update llama_cpp tool loop to show tool-specific status messages
and pass cancel_event through to executors. Rename composer
toggle from "Search" to "Tools" and show TerminalIcon for
execution status pills.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* studio: fix Nemotron/transformers 5.x support, onboarding navigation, port binding

Backend:
- Dynamic transformers 5.x detection via tokenizer_config.json fetch
  (checks for TokenizersBackend class, cached per-model)
- Bump transformers 5.x version from 5.2.0 to 5.3.0 across all workers,
  setup scripts (setup.sh, setup.ps1)
- Auto-enable trust_remote_code for unsloth/* models needing transformers 5.x
  (workaround for NemotronH config parsing bug in transformers)
- Auto-install mamba-ssm/causal-conv1d for SSM models (NemotronH, Falcon-H1)
  with --no-build-isolation --no-deps to avoid torch version conflicts
- Add SO_REUSEADDR to port check in run.py (fixes Colab proxy stale connection
  falsely reporting port as in-use)

Frontend:
- Fix "Skip to Chat" navigation: use window.location.href instead of React
  Router navigate() to bypass useEffect redirect race
- Fix "Skip Onboarding" on splash: navigates to /studio (not /chat)
- Fix onboarding guard: only check isOnboardingDone() on initial mount
- Fix Chat card on step 1: add sr-only spacer for consistent alignment
- Fix Chat+Text both selected: clear RadioGroup value when Chat is selected

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* studio: split tools toggle into Search and Code buttons

Replace the single "Tools" toggle with two independent toggles:
- "Search" (globe icon) enables web search only
- "Code" (terminal icon) enables Python and terminal execution

Add enabled_tools list field to the inference payload so the
backend only registers the tools the user has toggled on. Both
toggles appear in the main composer and the compare composer.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* studio: fix tool calling import validation and error logging

Replace unsloth_zoo-dependent import checker with a standalone
ast-based validator using sys.stdlib_module_names. This properly
blocks non-stdlib imports (numpy, requests, etc.) and returns a
clear error message to the model so it can rewrite using only
stdlib.

Add full traceback to tool streaming error logs for debugging.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* fix: parse gpt-oss harmony channels for clean safetensors chat output

gpt-oss models emit multi-channel output via harmony protocol tokens
(<|channel|>analysis<|message|>... and <|channel|>final<|message|>...).
TextIteratorStreamer with skip_special_tokens=True strips the special
tokens but leaves channel names concatenated with content, producing
garbled output like "analysisWe need to...assistantfinalHello!".

Add HarmonyTextStreamer that decodes with skip_special_tokens=False,
parses harmony markup via regex, and emits <think>analysis</think>
for the analysis channel and plain text for the final channel --
reusing the existing frontend reasoning UI.

Also expose supports_reasoning=True for non-GGUF gpt-oss models in
the /status endpoint so the frontend enables the Think toggle.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* studio: use unsloth_zoo for Python sandbox validation

Set UNSLOTH_IS_PRESENT=1 and import check_python_modules and
check_signal_escape_patterns directly from unsloth_zoo instead
of a standalone fallback. This gives us the full Unsloth
validation including stdlib-only import checks and signal/timeout
escape pattern detection.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* studio: allow all imports in Python tool sandbox

Remove stdlib-only import restriction. Keep signal escape
pattern detection via unsloth_zoo for safety.

* studio: fix ReadTimeout on tool streaming final pass

The 0.5s read timeout used for cancel-checking during streaming
also fires when waiting for the first response from llama-server
(e.g. reasoning model thinking for 15+ seconds). Add
_stream_with_retry() context manager that retries on ReadTimeout
while checking cancel_event, so the model has unlimited time to
think before producing the first token. Applied to both the
regular streaming path and the tool-calling final pass.

* fix: rewrite HarmonyTextStreamer with stateful incremental parsing

The delta-on-transformed approach had two critical bugs:

1. Before the full <|channel|>X<|message|> pattern was complete, the
   strip-tokens fallback emitted "analysis" as plain text. Then when
   the regex matched, _transform returned a completely different format
   (<think>...</think>) and the delta was computed against the wrong
   base string, producing fragments like "think>", "nk>", ">".

2. Even with full matches, the closing </think> tag shifted position
   as content grew, so text[prev_len:] produced garbled deltas.

Replace with stateful incremental parsing that:
- Buffers until a complete channel+message pair is seen
- Emits <think> once when analysis channel first appears
- Streams analysis content deltas (computed on channel content directly)
- Emits </think> once when final channel first appears
- Streams final content deltas
- Closes open think tags in end()

Also skip the generic all_special_tokens stripping in
_clean_generated_text for gpt-oss since HarmonyTextStreamer already
produces clean output and the generic stripping was mangling <think>
tags.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* fix: strip all <|...|> tokens in gpt-oss cleanup, not just harmony subset

The gpt-oss tokenizer has added tokens like <|return|> (id=200002) that
are not part of the harmony channel protocol but can leak into output.
The previous regex only stripped channel|message|start|end tokens.

Broaden the _clean_generated_text regex for gpt-oss to <\|[a-z_]+\|>
which catches all pipe-delimited tokens (return, constrain, reserved,
etc.) without matching <think>/<\/think> tags.

Verified: gpt-oss all_special_tokens are only <|return|>,
<|reserved_200017|>, <|startoftext|> -- none overlap with <think>.
The harmony tokens (channel, message, start, end) are added_tokens
but not in all_special_tokens.

* fix: hide config-only model repos from cached models list

Repos that only have metadata/config files cached (no .safetensors or
.bin weight files) were showing up in the Downloaded list with tiny
sizes like "1.8 KB" or "24 KB". These are just leftover config
snapshots from architecture checks, not usable models.

Filter the cached-models endpoint to only include repos that contain
actual model weight files (.safetensors or .bin).

* studio: fix toast description text contrast in dark mode

Add explicit !text-muted-foreground to toast description classNames
so secondary text (e.g. "Releases VRAM and resets inference state.")
is readable in dark mode.

* studio: fix Chat card icon alignment with size-4 spacer

Replace sr-only span (takes no space) with a size-4 shrink-0 div
matching the RadioGroupItem dimensions in other cards, so the Chat
icon aligns vertically with Text/Audio/Vision/Embeddings icons.

---------

Co-authored-by: workspace <user@workspace.local>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Manan17 <shahmanan170602@gmail.com>
Co-authored-by: Roland Tannous <rolandtannous@gravityq.ai>
2026-03-17 07:46:07 -07:00

1108 lines
39 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""
Model Management API routes
"""
import os
import sys
from pathlib import Path
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from typing import List, Optional
import structlog
from loggers import get_logger
import re as _re
_VALID_REPO_ID = _re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$")
def _is_valid_repo_id(repo_id: str) -> bool:
return bool(_VALID_REPO_ID.fullmatch(repo_id))
# Add backend directory to path
backend_path = Path(__file__).parent.parent.parent
if str(backend_path) not in sys.path:
sys.path.insert(0, str(backend_path))
from auth.authentication import get_current_subject
# Import backend functions
try:
from utils.models import (
scan_trained_loras,
scan_exported_models,
load_model_defaults,
get_base_model_from_lora,
is_vision_model,
is_embedding_model,
scan_checkpoints,
list_gguf_variants,
ModelConfig,
)
from utils.models.model_config import (
_pick_best_gguf,
_extract_quant_label,
is_audio_input_type,
)
from core.inference import get_inference_backend
from utils.paths import (
outputs_root,
exports_root,
resolve_output_dir,
resolve_export_dir,
)
except ImportError:
# Fallback: try to import from parent directory
parent_backend = backend_path.parent / "backend"
if str(parent_backend) not in sys.path:
sys.path.insert(0, str(parent_backend))
from utils.models import (
scan_trained_loras,
scan_exported_models,
load_model_defaults,
get_base_model_from_lora,
is_vision_model,
is_embedding_model,
scan_checkpoints,
list_gguf_variants,
ModelConfig,
)
from utils.models.model_config import (
_pick_best_gguf,
_extract_quant_label,
is_audio_input_type,
)
from core.inference import get_inference_backend
from utils.paths import (
outputs_root,
exports_root,
resolve_output_dir,
resolve_export_dir,
)
from models import (
CheckpointInfo,
CheckpointListResponse,
LocalModelInfo,
LocalModelListResponse,
ModelCheckpoints,
ModelDetails,
LoRAScanResponse,
LoRAInfo,
ModelListResponse,
)
from models.models import GgufVariantDetail, GgufVariantsResponse, ModelType
from models.responses import (
LoRABaseModelResponse,
VisionCheckResponse,
EmbeddingCheckResponse,
)
router = APIRouter()
logger = get_logger(__name__)
def derive_model_type(
is_vision: bool, audio_type: Optional[str], is_embedding: bool = False
) -> ModelType:
"""Collapse individual capability flags into a single model modality string."""
if is_embedding:
return "embeddings"
if audio_type is not None:
return "audio"
if is_vision:
return "vision"
return "text"
def _resolve_hf_cache_dir() -> Path:
"""Resolve local HF cache root used by hub downloads."""
try:
from huggingface_hub.constants import HF_HUB_CACHE
return Path(HF_HUB_CACHE)
except Exception:
return Path.home() / ".cache" / "huggingface" / "hub"
def _scan_models_dir(models_dir: Path) -> List[LocalModelInfo]:
if not models_dir.exists() or not models_dir.is_dir():
return []
found: List[LocalModelInfo] = []
for child in models_dir.iterdir():
if not child.is_dir():
continue
has_model_files = (
(child / "config.json").exists()
or (child / "adapter_config.json").exists()
or any(child.glob("*.safetensors"))
or any(child.glob("*.bin"))
or any(child.glob("*.gguf"))
)
if not has_model_files:
continue
try:
updated_at = child.stat().st_mtime
except OSError:
updated_at = None
found.append(
LocalModelInfo(
id = str(child),
display_name = child.name,
path = str(child),
source = "models_dir",
updated_at = updated_at,
),
)
# Also scan for standalone .gguf files directly in the models directory
for gguf_file in models_dir.glob("*.gguf"):
if gguf_file.is_file():
try:
updated_at = gguf_file.stat().st_mtime
except OSError:
updated_at = None
found.append(
LocalModelInfo(
id = str(gguf_file),
display_name = gguf_file.stem,
path = str(gguf_file),
source = "models_dir",
updated_at = updated_at,
),
)
return found
def _scan_hf_cache(cache_dir: Path) -> List[LocalModelInfo]:
if not cache_dir.exists() or not cache_dir.is_dir():
return []
found: List[LocalModelInfo] = []
for repo_dir in cache_dir.glob("models--*"):
if not repo_dir.is_dir():
continue
repo_name = repo_dir.name[len("models--") :]
if not repo_name:
continue
model_id = repo_name.replace("--", "/")
try:
updated_at = repo_dir.stat().st_mtime
except OSError:
updated_at = None
found.append(
LocalModelInfo(
id = model_id,
model_id = model_id,
display_name = model_id.split("/")[-1],
path = str(repo_dir),
source = "hf_cache",
updated_at = updated_at,
),
)
return found
@router.get("/local", response_model = LocalModelListResponse)
async def list_local_models(
models_dir: str = Query(
default = "./models", description = "Directory to scan for local model folders"
),
current_subject: str = Depends(get_current_subject),
):
"""
List local model candidates from custom models dir and HF cache.
"""
# Validate models_dir against an allowlist of trusted directories.
# Only the trusted Path objects are used for filesystem access -- the
# user-supplied string is only used for matching, never for path construction.
hf_cache_dir = _resolve_hf_cache_dir()
allowed_roots = [Path("./models").resolve(), hf_cache_dir]
try:
from utils.paths import studio_root, outputs_root
allowed_roots.extend([studio_root(), outputs_root()])
except Exception:
pass
requested = os.path.realpath(os.path.expanduser(models_dir))
models_root = None
for root in allowed_roots:
root_str = os.path.realpath(str(root))
if requested == root_str or requested.startswith(root_str + os.sep):
models_root = root # Use the trusted root, not the user-supplied path
break
if models_root is None:
raise HTTPException(
status_code = 403,
detail = "Directory not allowed",
)
try:
local_models = _scan_models_dir(models_root) + _scan_hf_cache(hf_cache_dir)
deduped: dict[str, LocalModelInfo] = {}
for model in local_models:
if model.id not in deduped:
deduped[model.id] = model
models = sorted(
deduped.values(),
key = lambda item: (item.updated_at or 0),
reverse = True,
)
return LocalModelListResponse(
models_dir = str(models_root),
hf_cache_dir = str(hf_cache_dir),
models = models,
)
except Exception as e:
logger.error(f"Error listing local models: {e}", exc_info = True)
raise HTTPException(
status_code = 500,
detail = f"Failed to list local models: {str(e)}",
)
@router.get("/list")
async def list_models(
current_subject: str = Depends(get_current_subject),
):
"""
List available models (default models and loaded models).
This endpoint returns the default models and any currently loaded models.
"""
try:
inference_backend = get_inference_backend()
# Get default models
default_models = inference_backend.default_models
# Get loaded models
loaded_models = []
for model_name, model_data in inference_backend.models.items():
_is_vision = model_data.get("is_vision", False)
_audio_type = model_data.get("audio_type")
model_info = ModelDetails(
id = model_name,
name = model_name.split("/")[-1] if "/" in model_name else model_name,
is_vision = _is_vision,
is_lora = model_data.get("is_lora", False),
is_audio = model_data.get("is_audio", False),
audio_type = _audio_type,
has_audio_input = model_data.get("has_audio_input", False),
model_type = derive_model_type(_is_vision, _audio_type),
)
loaded_models.append(model_info)
# Include active GGUF model (loaded via llama-server)
from routes.inference import get_llama_cpp_backend
llama_backend = get_llama_cpp_backend()
if llama_backend.is_loaded and llama_backend.model_identifier:
loaded_models.append(
ModelDetails(
id = llama_backend.model_identifier,
name = llama_backend.model_identifier.split("/")[-1],
is_gguf = True,
is_vision = llama_backend.is_vision,
is_audio = getattr(llama_backend, "_is_audio", False),
audio_type = getattr(llama_backend, "_audio_type", None),
)
)
# Combine default and loaded models
all_models = []
seen_ids = set()
# Add default models
for model_id in default_models:
if model_id not in seen_ids:
model_info = ModelDetails(
id = model_id,
name = model_id.split("/")[-1] if "/" in model_id else model_id,
is_gguf = model_id.upper().endswith("-GGUF"),
)
all_models.append(model_info)
seen_ids.add(model_id)
# Add loaded models
for model_info in loaded_models:
if model_info.id not in seen_ids:
all_models.append(model_info)
seen_ids.add(model_info.id)
return ModelListResponse(models = all_models, default_models = default_models)
except Exception as e:
logger.error(f"Error listing models: {e}", exc_info = True)
raise HTTPException(status_code = 500, detail = f"Failed to list models: {str(e)}")
def _get_max_position_embeddings(config) -> Optional[int]:
"""Extract max_position_embeddings from a model config, checking text_config fallback."""
if hasattr(config, "max_position_embeddings"):
return config.max_position_embeddings
if hasattr(config, "text_config") and hasattr(
config.text_config, "max_position_embeddings"
):
return config.text_config.max_position_embeddings
return None
def _get_model_size_bytes(
model_name: str, hf_token: Optional[str] = None
) -> Optional[int]:
"""Get total size of model weight files from HF Hub."""
try:
from huggingface_hub import HfApi
api = HfApi(token = hf_token)
info = api.repo_info(model_name, repo_type = "model", token = hf_token)
if not info.siblings:
return None
weight_exts = (".safetensors", ".bin", ".pt", ".pth", ".gguf")
total = 0
for sibling in info.siblings:
if sibling.rfilename and any(
sibling.rfilename.endswith(ext) for ext in weight_exts
):
if sibling.size is not None:
total += sibling.size
return total if total > 0 else None
except Exception as e:
logger.warning(f"Could not get model size for {model_name}: {e}")
return None
@router.get("/config/{model_name:path}")
async def get_model_config(
model_name: str,
hf_token: Optional[str] = Query(None),
current_subject: str = Depends(get_current_subject),
):
"""
Get configuration for a specific model.
This endpoint wraps the backend load_model_defaults function.
"""
try:
from utils.models.model_config import is_local_path
if not is_local_path(model_name):
model_name = model_name.lower()
logger.info(f"Getting model config for: {model_name}")
from utils.models.model_config import detect_audio_type
# Load model defaults from backend
config_dict = load_model_defaults(model_name)
# Detect model capabilities (pass HF token for gated models)
is_vision = is_vision_model(model_name)
is_embedding = is_embedding_model(model_name, hf_token = hf_token)
audio_type = detect_audio_type(model_name, hf_token = hf_token)
# Check if it's a LoRA adapter
is_lora = False
base_model = None
max_position_embeddings = None
try:
model_config = ModelConfig.from_identifier(model_name)
is_lora = model_config.is_lora
base_model = model_config.base_model if is_lora else None
max_position_embeddings = _get_max_position_embeddings(model_config)
except Exception:
pass
# Fallback: try AutoConfig directly if not found yet
if max_position_embeddings is None:
try:
from transformers import AutoConfig as _AutoConfig
_trust = model_name.lower().startswith("unsloth/")
_ac = _AutoConfig.from_pretrained(
model_name, trust_remote_code = _trust, token = hf_token
)
max_position_embeddings = _get_max_position_embeddings(_ac)
except Exception:
pass
logger.info(
f"Model config result for {model_name}: is_vision={is_vision}, is_embedding={is_embedding}, audio_type={audio_type}, is_lora={is_lora}, max_position_embeddings={max_position_embeddings}"
)
return ModelDetails(
id = model_name,
model_name = model_name,
config = config_dict,
is_vision = is_vision,
is_embedding = is_embedding,
is_lora = is_lora,
is_audio = audio_type is not None,
audio_type = audio_type,
has_audio_input = is_audio_input_type(audio_type),
model_type = derive_model_type(is_vision, audio_type, is_embedding),
base_model = base_model,
max_position_embeddings = max_position_embeddings,
model_size_bytes = _get_model_size_bytes(model_name, hf_token),
)
except Exception as e:
logger.error(f"Error getting model config: {e}", exc_info = True)
raise HTTPException(
status_code = 500, detail = f"Failed to get model config: {str(e)}"
)
@router.get("/loras")
async def scan_loras(
outputs_dir: str = Query(
default = str(outputs_root()), description = "Directory to scan for LoRA adapters"
),
exports_dir: str = Query(
default = str(exports_root()), description = "Directory to scan for exported models"
),
current_subject: str = Depends(get_current_subject),
):
"""
Scan for trained LoRA adapters and exported models.
Returns both training outputs (from outputs_dir) and exported models
(from exports_dir) in a single list, distinguished by source field.
"""
try:
resolved_outputs_dir = str(resolve_output_dir(outputs_dir))
resolved_exports_dir = str(resolve_export_dir(exports_dir))
lora_list = []
# Scan training outputs
trained_loras = scan_trained_loras(outputs_dir = resolved_outputs_dir)
for display_name, adapter_path in trained_loras:
base_model = get_base_model_from_lora(adapter_path)
lora_list.append(
LoRAInfo(
display_name = display_name,
adapter_path = adapter_path,
base_model = base_model,
source = "training",
)
)
# Scan exported models (merged, LoRA, base — skips GGUF)
exported = scan_exported_models(exports_dir = resolved_exports_dir)
for display_name, model_path, export_type, base_model in exported:
lora_list.append(
LoRAInfo(
display_name = display_name,
adapter_path = model_path,
base_model = base_model,
source = "exported",
export_type = export_type,
)
)
return LoRAScanResponse(loras = lora_list, outputs_dir = resolved_outputs_dir)
except Exception as e:
logger.error(f"Error scanning LoRAs: {e}", exc_info = True)
raise HTTPException(
status_code = 500, detail = f"Failed to scan LoRA adapters: {str(e)}"
)
@router.get("/loras/{lora_path:path}/base-model", response_model = LoRABaseModelResponse)
async def get_lora_base_model(
lora_path: str,
current_subject: str = Depends(get_current_subject),
):
"""
Get the base model for a LoRA adapter.
This endpoint wraps the backend get_base_model_from_lora function.
"""
try:
base_model = get_base_model_from_lora(lora_path)
if base_model is None:
raise HTTPException(
status_code = 404,
detail = f"Could not determine base model for LoRA: {lora_path}",
)
return LoRABaseModelResponse(
lora_path = lora_path,
base_model = base_model,
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting LoRA base model: {e}", exc_info = True)
raise HTTPException(
status_code = 500, detail = f"Failed to get base model: {str(e)}"
)
@router.get("/check-vision/{model_name:path}", response_model = VisionCheckResponse)
async def check_vision_model(
model_name: str,
current_subject: str = Depends(get_current_subject),
):
"""
Check if a model is a vision model.
This endpoint wraps the backend is_vision_model function.
"""
try:
logger.info(f"Checking if vision model: {model_name}")
is_vision = is_vision_model(model_name)
logger.info(f"Vision check result for {model_name}: is_vision={is_vision}")
return VisionCheckResponse(
model_name = model_name,
is_vision = is_vision,
)
except Exception as e:
logger.error(f"Error checking vision model: {e}", exc_info = True)
raise HTTPException(
status_code = 500, detail = f"Failed to check vision model: {str(e)}"
)
@router.get("/check-embedding/{model_name:path}", response_model = EmbeddingCheckResponse)
async def check_embedding_model(
model_name: str,
hf_token: Optional[str] = Query(None),
current_subject: str = Depends(get_current_subject),
):
"""
Check if a model is an embedding model.
This endpoint wraps the backend is_embedding_model function.
"""
try:
logger.info(f"Checking if embedding model: {model_name}")
is_embedding = is_embedding_model(model_name, hf_token = hf_token)
logger.info(
f"Embedding check result for {model_name}: is_embedding={is_embedding}"
)
return EmbeddingCheckResponse(
model_name = model_name,
is_embedding = is_embedding,
)
except Exception as e:
logger.error(f"Error checking embedding model: {e}", exc_info = True)
raise HTTPException(
status_code = 500, detail = f"Failed to check embedding model: {str(e)}"
)
@router.get("/gguf-variants", response_model = GgufVariantsResponse)
async def get_gguf_variants(
repo_id: str = Query(
..., description = "HuggingFace repo ID (e.g. 'unsloth/gemma-3-4b-it-GGUF')"
),
hf_token: Optional[str] = Query(
None, description = "HuggingFace token for private repos"
),
current_subject: str = Depends(get_current_subject),
):
"""
List available GGUF quantization variants for a HuggingFace repo.
Returns all available quantization variants (Q4_K_M, Q8_0, BF16, etc.)
with file sizes, whether the model supports vision, and the recommended
default variant.
"""
try:
variants, has_vision = list_gguf_variants(repo_id, hf_token = hf_token)
# Determine default variant
filenames = [v.filename for v in variants]
best = _pick_best_gguf(filenames)
default_variant = _extract_quant_label(best) if best else None
# Check which variants are fully downloaded in the HF cache.
# For split GGUFs, ALL shards must be present -- sum cached bytes
# per variant and compare against the expected total.
# HF cache dir uses the exact case from the repo_id at download time,
# which may differ from the canonical HF repo_id, so do a
# case-insensitive match.
cached_bytes_by_quant: dict[str, int] = {}
try:
import re as _re
from huggingface_hub import constants as hf_constants
# Sanitize repo_id: must be "owner/name" with safe chars only
if not _is_valid_repo_id(repo_id):
raise ValueError(f"Invalid repo_id format: {repo_id}")
cache_dir = Path(hf_constants.HF_HUB_CACHE)
target = f"models--{repo_id.replace('/', '--')}".lower()
for entry in cache_dir.iterdir():
if entry.name.lower() == target:
snapshots = entry / "snapshots"
if snapshots.is_dir():
for snap in snapshots.iterdir():
for f in snap.rglob("*.gguf"):
q = _extract_quant_label(f.name)
cached_bytes_by_quant[q] = (
cached_bytes_by_quant.get(q, 0) + f.stat().st_size
)
break
except Exception:
pass
def _is_fully_downloaded(variant) -> bool:
cached = cached_bytes_by_quant.get(variant.quant, 0)
if cached == 0 or variant.size_bytes == 0:
return False
# Allow small rounding tolerance (symlinks vs real sizes)
return cached >= variant.size_bytes * 0.99
return GgufVariantsResponse(
repo_id = repo_id,
variants = [
GgufVariantDetail(
filename = v.filename,
quant = v.quant,
size_bytes = v.size_bytes,
downloaded = _is_fully_downloaded(v),
)
for v in variants
],
has_vision = has_vision,
default_variant = default_variant,
)
except Exception as e:
logger.error(f"Error listing GGUF variants for '{repo_id}': {e}", exc_info = True)
raise HTTPException(
status_code = 500,
detail = f"Failed to list GGUF variants: {str(e)}",
)
@router.get("/gguf-download-progress")
async def get_gguf_download_progress(
repo_id: str = Query(..., description = "HuggingFace repo ID"),
variant: str = Query("", description = "Quantization variant (e.g. UD-TQ1_0)"),
expected_bytes: int = Query(0, description = "Expected total download size in bytes"),
current_subject: str = Depends(get_current_subject),
):
"""Return download progress by checking cached GGUF files for a specific variant.
Tracks completed shard downloads in snapshots and in-progress downloads
in the blobs directory (incomplete files).
"""
try:
if not _is_valid_repo_id(repo_id):
return {
"downloaded_bytes": 0,
"expected_bytes": expected_bytes,
"progress": 0,
}
from huggingface_hub import constants as hf_constants
cache_dir = Path(hf_constants.HF_HUB_CACHE)
target = f"models--{repo_id.replace('/', '--')}".lower()
variant_lower = variant.lower().replace("-", "").replace("_", "")
downloaded_bytes = 0
in_progress_bytes = 0
for entry in cache_dir.iterdir():
if entry.name.lower() == target:
# Count completed .gguf files matching this variant in snapshots
for f in entry.rglob("*.gguf"):
fname = f.name.lower().replace("-", "").replace("_", "")
if not variant_lower or variant_lower in fname:
downloaded_bytes += f.stat().st_size
# Check blobs for in-progress downloads (.incomplete files)
blobs_dir = entry / "blobs"
if blobs_dir.is_dir():
for f in blobs_dir.iterdir():
if f.is_file() and f.name.endswith(".incomplete"):
in_progress_bytes += f.stat().st_size
break
total_progress_bytes = downloaded_bytes + in_progress_bytes
progress = (
min(total_progress_bytes / expected_bytes, 0.99)
if expected_bytes > 0
else 0
)
# Only report 1.0 when all bytes are in completed files (not in-progress)
if expected_bytes > 0 and downloaded_bytes >= expected_bytes:
progress = 1.0
return {
"downloaded_bytes": total_progress_bytes,
"expected_bytes": expected_bytes,
"progress": round(progress, 3),
}
except Exception:
return {"downloaded_bytes": 0, "expected_bytes": expected_bytes, "progress": 0}
@router.get("/download-progress")
async def get_download_progress(
repo_id: str = Query(..., description = "HuggingFace repo ID"),
current_subject: str = Depends(get_current_subject),
):
"""Return download progress for any HuggingFace model repo.
Checks the local HF cache for completed blobs and in-progress
(.incomplete) downloads. Uses the HF API to determine the expected
total size on the first call, then caches it for subsequent polls.
"""
_empty = {"downloaded_bytes": 0, "expected_bytes": 0, "progress": 0}
try:
if not _is_valid_repo_id(repo_id):
return _empty
from huggingface_hub import constants as hf_constants
cache_dir = Path(hf_constants.HF_HUB_CACHE)
target = f"models--{repo_id.replace('/', '--')}".lower()
completed_bytes = 0
in_progress_bytes = 0
for entry in cache_dir.iterdir():
if entry.name.lower() != target:
continue
blobs_dir = entry / "blobs"
if not blobs_dir.is_dir():
break
for f in blobs_dir.iterdir():
if not f.is_file():
continue
if f.name.endswith(".incomplete"):
in_progress_bytes += f.stat().st_size
else:
completed_bytes += f.stat().st_size
break
downloaded_bytes = completed_bytes + in_progress_bytes
if downloaded_bytes == 0:
return _empty
# Get expected size from HF API (cached per repo_id)
expected_bytes = _get_repo_size_cached(repo_id)
if expected_bytes <= 0:
# Cannot determine total; report bytes only, no percentage
return {
"downloaded_bytes": downloaded_bytes,
"expected_bytes": 0,
"progress": 0,
}
# Use 95% threshold for completion (blob deduplication can make
# completed_bytes differ slightly from expected_bytes).
# Do NOT use "no .incomplete files" as a completion signal --
# HF downloads files sequentially, so between files there are
# no .incomplete files even though the download is far from done.
if completed_bytes >= expected_bytes * 0.95:
progress = 1.0
else:
progress = min(downloaded_bytes / expected_bytes, 0.99)
return {
"downloaded_bytes": downloaded_bytes,
"expected_bytes": expected_bytes,
"progress": round(progress, 3),
}
except Exception as e:
logger.warning(f"Error checking download progress for {repo_id}: {e}")
return _empty
_repo_size_cache: dict[str, int] = {}
def _get_repo_size_cached(repo_id: str) -> int:
if repo_id in _repo_size_cache:
return _repo_size_cache[repo_id]
try:
from huggingface_hub import model_info as hf_model_info
info = hf_model_info(repo_id, token = None, files_metadata = True)
total = sum(s.size for s in info.siblings if s.size)
_repo_size_cache[repo_id] = total
return total
except Exception as e:
logger.warning(f"Failed to get repo size for {repo_id}: {e}")
return 0
@router.get("/cached-gguf")
async def list_cached_gguf(
current_subject: str = Depends(get_current_subject),
):
"""List GGUF repos that have already been downloaded to the HF cache.
Uses scan_cache_dir() for proper repo IDs, then deduplicates by
lowercased key (HF cache dirs are lowercased but the canonical repo
ID preserves casing).
"""
try:
from huggingface_hub import scan_cache_dir
hf_cache = scan_cache_dir()
seen_lower: dict[str, dict] = {}
for repo_info in hf_cache.repos:
if repo_info.repo_type != "model":
continue
repo_id = repo_info.repo_id
if not repo_id.upper().endswith("-GGUF"):
continue
# Check for actual .gguf files and sum sizes
total_size = 0
has_gguf = False
for revision in repo_info.revisions:
for f in revision.files:
if f.file_name.endswith(".gguf"):
has_gguf = True
total_size += f.size_on_disk
if not has_gguf:
continue
# Deduplicate: keep the entry with the most data
key = repo_id.lower()
existing = seen_lower.get(key)
if existing is None or total_size > existing["size_bytes"]:
seen_lower[key] = {
"repo_id": repo_id,
"size_bytes": total_size,
"cache_path": str(repo_info.repo_path),
}
cached = sorted(seen_lower.values(), key = lambda c: c["repo_id"])
return {"cached": cached}
except Exception as e:
logger.error(f"Error listing cached GGUF repos: {e}", exc_info = True)
return {"cached": []}
@router.get("/cached-models")
async def list_cached_models(
current_subject: str = Depends(get_current_subject),
):
"""List non-GGUF model repos that have been downloaded to the HF cache.
Only includes repos that actually contain model weight files
(.safetensors, .bin), not repos with only config/metadata.
"""
_WEIGHT_EXTENSIONS = (".safetensors", ".bin")
try:
from huggingface_hub import scan_cache_dir
hf_cache = scan_cache_dir()
seen_lower: dict[str, dict] = {}
for repo_info in hf_cache.repos:
if repo_info.repo_type != "model":
continue
repo_id = repo_info.repo_id
if repo_id.upper().endswith("-GGUF"):
continue
total_size = sum(
f.size_on_disk for rev in repo_info.revisions for f in rev.files
)
if total_size == 0:
continue
# Skip repos that only have config/metadata files (no weights)
has_weights = any(
f.file_name.endswith(_WEIGHT_EXTENSIONS)
for rev in repo_info.revisions
for f in rev.files
)
if not has_weights:
continue
key = repo_id.lower()
existing = seen_lower.get(key)
if existing is None or total_size > existing["size_bytes"]:
seen_lower[key] = {
"repo_id": repo_id,
"size_bytes": total_size,
}
cached = sorted(seen_lower.values(), key = lambda c: c["repo_id"])
return {"cached": cached}
except Exception as e:
logger.error(f"Error listing cached models: {e}", exc_info = True)
return {"cached": []}
@router.delete("/delete-cached")
async def delete_cached_model(
repo_id: str = Body(...),
variant: Optional[str] = Body(None),
current_subject: str = Depends(get_current_subject),
):
"""Delete a cached model repo (or a specific GGUF variant) from the HF cache.
When *variant* is provided, only the GGUF files matching that quant label
are removed (e.g. ``UD-Q4_K_XL``). Otherwise the entire repo is deleted.
Refuses if the model is currently loaded for inference.
"""
if not _is_valid_repo_id(repo_id):
raise HTTPException(status_code = 400, detail = "Invalid repo_id format")
# Check if model is currently loaded
try:
from routes.inference import get_llama_cpp_backend
llama_backend = get_llama_cpp_backend()
if llama_backend.is_loaded and llama_backend.model_identifier:
loaded_id = llama_backend.model_identifier.lower()
if loaded_id == repo_id.lower() or loaded_id.startswith(repo_id.lower()):
raise HTTPException(
status_code = 400,
detail = "Unload the model before deleting",
)
except HTTPException:
raise
except Exception:
pass
try:
inference_backend = get_inference_backend()
if inference_backend.active_model_name:
active = inference_backend.active_model_name.lower()
if active == repo_id.lower() or active.startswith(repo_id.lower()):
raise HTTPException(
status_code = 400,
detail = "Unload the model before deleting",
)
except HTTPException:
raise
except Exception:
pass
try:
from huggingface_hub import scan_cache_dir
hf_cache = scan_cache_dir()
target_repo = None
for repo_info in hf_cache.repos:
if repo_info.repo_type != "model":
continue
if repo_info.repo_id.lower() == repo_id.lower():
target_repo = repo_info
break
if target_repo is None:
raise HTTPException(status_code = 404, detail = "Model not found in cache")
# ── Per-variant GGUF deletion ────────────────────────────
if variant:
deleted_bytes = 0
deleted_count = 0
for rev in target_repo.revisions:
for f in rev.files:
if not f.file_name.endswith(".gguf"):
continue
quant = _extract_quant_label(f.file_name)
if quant.lower() != variant.lower():
continue
# Delete the blob (actual data) and the snapshot symlink
try:
blob = Path(f.blob_path)
snap = Path(f.file_path)
size = blob.stat().st_size if blob.exists() else 0
if snap.exists() or snap.is_symlink():
snap.unlink()
if blob.exists():
blob.unlink()
deleted_bytes += size
deleted_count += 1
except Exception as e:
logger.warning(f"Failed to delete {f.file_name}: {e}")
if deleted_count == 0:
raise HTTPException(
status_code = 404,
detail = f"Variant {variant} not found in cache for {repo_id}",
)
freed_mb = deleted_bytes / (1024 * 1024)
logger.info(
f"Deleted {deleted_count} file(s) for {repo_id} variant {variant}: "
f"{freed_mb:.1f} MB freed"
)
return {"status": "deleted", "repo_id": repo_id, "variant": variant}
# ── Full repo deletion ───────────────────────────────────
revision_hashes = [rev.commit_hash for rev in target_repo.revisions]
if not revision_hashes:
raise HTTPException(status_code = 404, detail = "No revisions found for model")
delete_strategy = hf_cache.delete_revisions(*revision_hashes)
logger.info(
f"Deleting cached model {repo_id}: "
f"{delete_strategy.expected_freed_size_str} will be freed"
)
delete_strategy.execute()
return {"status": "deleted", "repo_id": repo_id}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error deleting cached model {repo_id}: {e}", exc_info = True)
raise HTTPException(
status_code = 500,
detail = f"Failed to delete cached model: {str(e)}",
)
@router.get("/checkpoints", response_model = CheckpointListResponse)
async def list_checkpoints(
outputs_dir: str = Query(
default = str(outputs_root()),
description = "Directory to scan for checkpoints",
),
current_subject: str = Depends(get_current_subject),
):
"""
List available checkpoints in the outputs directory.
Scans the outputs folder for training runs and their checkpoints.
"""
try:
resolved_outputs_dir = str(resolve_output_dir(outputs_dir))
raw_models = scan_checkpoints(outputs_dir = resolved_outputs_dir)
models = [
ModelCheckpoints(
name = model_name,
checkpoints = [
CheckpointInfo(display_name = display_name, path = path, loss = loss)
for display_name, path, loss in checkpoints
],
base_model = metadata.get("base_model"),
peft_type = metadata.get("peft_type"),
lora_rank = metadata.get("lora_rank"),
)
for model_name, checkpoints, metadata in raw_models
]
return CheckpointListResponse(
outputs_dir = resolved_outputs_dir,
models = models,
)
except Exception as e:
logger.error(f"Error listing checkpoints: {e}", exc_info = True)
raise HTTPException(
status_code = 500,
detail = f"Failed to list checkpoints: {str(e)}",
)