Studio: make code comments and docstrings more succinct (#6029)
Trim and tighten code comments and docstrings across studio/ Python. Comment-only: every changed file verified code-identical to main via AST/token comparison.
This commit is contained in:
parent
ebf28e7e07
commit
8292e699e4
205 changed files with 8065 additions and 8912 deletions
|
|
@ -4,17 +4,17 @@
|
|||
"""
|
||||
Compatibility shim for Anaconda/conda-forge Python builds.
|
||||
|
||||
Anaconda modifies sys.version to include distributor metadata between pipe
|
||||
characters, e.g. '3.12.4 | packaged by Anaconda, Inc. | (main, ...) [MSC ...]'.
|
||||
Python's platform._sys_version() has a hardcoded regex that cannot parse this,
|
||||
raising ValueError. CPython closed this as "not planned" (cpython#102396).
|
||||
Anaconda puts distributor metadata between pipes in sys.version, e.g.
|
||||
'3.12.4 | packaged by Anaconda, Inc. | (main, ...) [MSC ...]'. The hardcoded
|
||||
regex in platform._sys_version() can't parse this and raises ValueError;
|
||||
CPython closed it as "not planned" (cpython#102396).
|
||||
|
||||
This module seeds platform._sys_version_cache so the stdlib parser never sees
|
||||
the problematic string, fixing the import chain:
|
||||
We seed platform._sys_version_cache so the stdlib parser never sees the bad
|
||||
string, fixing the import chain:
|
||||
structlog -> rich.pretty -> attrs._compat -> platform.python_implementation()
|
||||
|
||||
Import this module before any library imports that may trigger the above chain.
|
||||
Safe to import multiple times (no-op if cache is already seeded or no pipes).
|
||||
Import before any library that may trigger that chain. Idempotent (no-op if the
|
||||
cache is already seeded or there are no pipes).
|
||||
"""
|
||||
|
||||
import platform
|
||||
|
|
@ -29,12 +29,12 @@ def _seed_sys_version_cache() -> None:
|
|||
# Strip paired |...| segments (Anaconda, conda-forge metadata)
|
||||
cleaned = re.sub(r"\s*\|[^|]*\|\s*", " ", raw).strip()
|
||||
|
||||
# Format B: "ver (build) | label | (build_dup) \n[compiler]"
|
||||
# After pipe-strip, two consecutive (...) groups remain; drop the second.
|
||||
# Format B: "ver (build) | label | (build_dup) \n[compiler]" leaves two
|
||||
# consecutive (...) groups after pipe-strip; drop the second.
|
||||
cleaned = re.sub(r"(\([^)]*\))\s+\([^)]*\)", r"\1", cleaned)
|
||||
|
||||
if "|" in cleaned:
|
||||
# Unpaired pipe remaining -- keep version + everything from "(" onward
|
||||
# Unpaired pipe left: keep version + everything from "(" onward
|
||||
m = re.match(r"([\w.+]+)\s*", cleaned)
|
||||
p = cleaned.find("(")
|
||||
if m and p > 0:
|
||||
|
|
@ -47,9 +47,9 @@ def _seed_sys_version_cache() -> None:
|
|||
try:
|
||||
result = platform._sys_version(cleaned)
|
||||
except ValueError:
|
||||
return # Cleaning didn't produce a parseable string; don't make things worse
|
||||
return # Still unparsable; don't make things worse
|
||||
|
||||
# Seed the cache so future calls with the raw string skip parsing entirely
|
||||
# Seed the cache so future calls with the raw string skip parsing
|
||||
cache = getattr(platform, "_sys_version_cache", None)
|
||||
if isinstance(cache, dict):
|
||||
cache[raw] = result
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""
|
||||
Authentication module for JWT-based auth with SQLite storage.
|
||||
"""
|
||||
"""Authentication module for JWT-based auth with SQLite storage."""
|
||||
|
||||
from .authentication import (
|
||||
create_access_token,
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ def create_access_token(
|
|||
"""
|
||||
Create a signed JWT for the given subject (e.g. username).
|
||||
|
||||
Tokens are valid across restarts because the signing secret is stored in SQLite.
|
||||
Valid across restarts: the signing secret is stored in SQLite.
|
||||
"""
|
||||
to_encode = {"sub": subject}
|
||||
if desktop:
|
||||
|
|
@ -100,7 +100,7 @@ def create_refresh_token(subject: str, *, desktop: bool = False) -> str:
|
|||
"""
|
||||
Create a random refresh token, store its hash in SQLite, and return it.
|
||||
|
||||
Refresh tokens are opaque (not JWTs) and expire after REFRESH_TOKEN_EXPIRE_DAYS.
|
||||
Refresh tokens are opaque (not JWTs); expire after REFRESH_TOKEN_EXPIRE_DAYS.
|
||||
"""
|
||||
token = secrets.token_urlsafe(48)
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(days = REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
|
|
@ -112,8 +112,8 @@ def refresh_access_token(refresh_token: str) -> Tuple[Optional[str], Optional[st
|
|||
"""
|
||||
Validate a refresh token and issue a new access token.
|
||||
|
||||
The refresh token itself is NOT consumed — it stays valid until expiry.
|
||||
Returns a new access_token or None if the refresh token is invalid/expired.
|
||||
The refresh token is NOT consumed; it stays valid until expiry.
|
||||
Returns a new access_token, or None if the refresh token is invalid/expired.
|
||||
"""
|
||||
verified = verify_refresh_token(refresh_token)
|
||||
if verified is None:
|
||||
|
|
@ -128,7 +128,7 @@ def refresh_access_token(refresh_token: str) -> Tuple[Optional[str], Optional[st
|
|||
|
||||
def reload_secret() -> None:
|
||||
"""
|
||||
Keep legacy API compatibility for callers expecting auth storage init.
|
||||
Legacy API compat for callers expecting auth storage init.
|
||||
|
||||
Auth now resolves the current signing secret directly from SQLite.
|
||||
"""
|
||||
|
|
@ -157,9 +157,9 @@ async def _get_current_subject(
|
|||
credentials: HTTPAuthorizationCredentials, *, allow_password_change: bool
|
||||
) -> str:
|
||||
"""
|
||||
FastAPI dependency to validate the JWT and return the subject.
|
||||
FastAPI dependency: validate the JWT and return the subject.
|
||||
|
||||
Use this as a dependency on routes that should be protected, e.g.:
|
||||
Use as a dependency on protected routes, e.g.:
|
||||
|
||||
@router.get("/secure")
|
||||
async def secure_endpoint(current_subject: str = Depends(get_current_subject)):
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""
|
||||
SQLite storage for authentication data (user credentials + JWT secret).
|
||||
"""
|
||||
"""SQLite storage for auth data (user credentials + JWT secret)."""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
|
|
@ -17,20 +15,19 @@ from utils.paths import auth_db_path, ensure_dir
|
|||
DB_PATH = auth_db_path()
|
||||
DEFAULT_ADMIN_USERNAME = "unsloth"
|
||||
|
||||
# Plaintext bootstrap password file — lives beside auth.db, deleted on
|
||||
# first password change so the credential never lingers on disk.
|
||||
# Plaintext bootstrap password file beside auth.db, deleted on first password
|
||||
# change so the credential never lingers on disk.
|
||||
_BOOTSTRAP_PW_PATH = DB_PATH.parent / ".bootstrap_password"
|
||||
|
||||
# In-process cache so we don't re-read the file on every HTML serve.
|
||||
# In-process cache to avoid re-reading the file on every HTML serve.
|
||||
_bootstrap_password: Optional[str] = None
|
||||
|
||||
|
||||
def generate_bootstrap_password() -> str:
|
||||
"""Generate a 4-word diceware passphrase and persist it to disk.
|
||||
|
||||
The passphrase is written to ``_BOOTSTRAP_PW_PATH`` so that it
|
||||
survives server restarts (the DB only stores the *hash*). On
|
||||
subsequent calls / restarts, the persisted value is returned.
|
||||
Written to ``_BOOTSTRAP_PW_PATH`` so it survives restarts (the DB only
|
||||
stores the *hash*). Later calls / restarts return the persisted value.
|
||||
"""
|
||||
global _bootstrap_password
|
||||
|
||||
|
|
@ -51,7 +48,7 @@ def generate_bootstrap_password() -> str:
|
|||
options = diceware.handle_options(args = ["-n", "4", "-d", "", "-c"])
|
||||
)
|
||||
|
||||
# Persist so the *same* passphrase is used if the server restarts
|
||||
# Persist so the *same* passphrase is reused if the server restarts
|
||||
# before the user changes the password.
|
||||
ensure_dir(_BOOTSTRAP_PW_PATH.parent)
|
||||
_BOOTSTRAP_PW_PATH.write_text(_bootstrap_password)
|
||||
|
|
@ -88,21 +85,19 @@ def clear_bootstrap_password() -> None:
|
|||
|
||||
|
||||
def _hash_token(token: str) -> str:
|
||||
"""SHA-256 hash helper used for refresh token storage.
|
||||
"""SHA-256 hash helper for refresh token storage.
|
||||
|
||||
Plain SHA-256 is intentional here: refresh tokens are high-entropy
|
||||
random strings from ``secrets.token_urlsafe(48)`` (384 bits of
|
||||
entropy), so a slow KDF (Argon2 / bcrypt / PBKDF2) provides zero
|
||||
additional security — no attacker can brute-force 2^384 regardless
|
||||
of hash speed — while adding tens of ms of CPU to every refresh.
|
||||
See the OWASP Password Storage Cheat Sheet on fast-vs-slow hashing
|
||||
of high-entropy inputs.
|
||||
Plain SHA-256 is intentional: refresh tokens are high-entropy random
|
||||
strings from ``secrets.token_urlsafe(48)`` (384 bits), so a slow KDF
|
||||
(Argon2 / bcrypt / PBKDF2) adds zero security (2^384 is unbruteforceable
|
||||
regardless of hash speed) while costing tens of ms per refresh. See the
|
||||
OWASP Password Storage Cheat Sheet on hashing high-entropy inputs.
|
||||
|
||||
API keys use the separate ``_pbkdf2_api_key`` helper below, which
|
||||
runs PBKDF2-HMAC-SHA256 with a persistent server-side salt — not
|
||||
for cryptographic reasons (128-bit random tokens don't need slow
|
||||
hashing), but because CodeQL's ``py/weak-sensitive-data-hashing``
|
||||
query mislabels API keys as passwords and demands a KDF.
|
||||
API keys use the separate ``_pbkdf2_api_key`` helper (PBKDF2-HMAC-SHA256
|
||||
with a persistent server-side salt) — not for crypto reasons (128-bit
|
||||
random tokens don't need slow hashing) but because CodeQL's
|
||||
``py/weak-sensitive-data-hashing`` query mislabels them as passwords and
|
||||
demands a KDF.
|
||||
"""
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
|
@ -176,22 +171,20 @@ def get_connection() -> sqlite3.Connection:
|
|||
|
||||
# ── API-key PBKDF2 salt ────────────────────────────────────────────────
|
||||
#
|
||||
# Module-level cache for the persistent API-key PBKDF2 salt. Populated
|
||||
# lazily on first use via ``_get_or_create_api_key_pbkdf2_salt``. Not
|
||||
# protected by a lock because (a) the ``INSERT OR IGNORE`` provides
|
||||
# atomicity at the SQLite layer and (b) concurrent populations converge
|
||||
# on the same value, so the worst case is a harmless duplicate read on
|
||||
# startup.
|
||||
# Module-level cache for the persistent API-key PBKDF2 salt, populated lazily
|
||||
# via ``_get_or_create_api_key_pbkdf2_salt``. No lock needed: (a) ``INSERT OR
|
||||
# IGNORE`` is atomic at the SQLite layer and (b) concurrent populations
|
||||
# converge on the same value, so the worst case is a harmless duplicate read
|
||||
# on startup.
|
||||
_api_key_pbkdf2_salt_cache: Optional[bytes] = None
|
||||
|
||||
|
||||
def _get_or_create_api_key_pbkdf2_salt() -> bytes:
|
||||
"""Return the persistent API-key PBKDF2 salt, generating it once if missing.
|
||||
|
||||
Stored as a hex-encoded 32-byte random value in the ``app_secrets``
|
||||
table under key ``"api_key_pbkdf2_salt"``. Regenerated only if the row
|
||||
is missing (i.e. fresh install, or operator manually deleted the row
|
||||
and accepts invalidating existing API keys).
|
||||
Stored as a hex-encoded 32-byte random value in ``app_secrets`` under key
|
||||
``"api_key_pbkdf2_salt"``. Regenerated only when the row is missing (fresh
|
||||
install, or operator deleted it and accepts invalidating existing keys).
|
||||
"""
|
||||
global _api_key_pbkdf2_salt_cache
|
||||
if _api_key_pbkdf2_salt_cache is not None:
|
||||
|
|
@ -233,22 +226,18 @@ _DESKTOP_SECRET_CREATED_AT_KEY = "desktop_secret_created_at"
|
|||
def _pbkdf2_api_key(raw_key: str) -> str:
|
||||
"""PBKDF2-HMAC-SHA256 an API key with a persistent server-side salt.
|
||||
|
||||
Used for API-key storage ONLY, not refresh tokens. Matches the
|
||||
PBKDF2 algorithm + iteration count used by the password hasher in
|
||||
``auth/hashing.py`` so the codebase is consistent on which KDF it
|
||||
uses for credential storage.
|
||||
For API-key storage ONLY, not refresh tokens. Matches the PBKDF2 algorithm
|
||||
+ iteration count used by the password hasher in ``auth/hashing.py`` so
|
||||
the codebase is consistent on its credential-storage KDF.
|
||||
|
||||
Notes on why a slow KDF here is *only* a CodeQL appeasement and
|
||||
*not* a cryptographic requirement: API keys are cryptographically
|
||||
random 128-bit tokens (via ``secrets.token_hex``), so brute force
|
||||
against 2^128 is infeasible regardless of hash speed. CodeQL's
|
||||
``py/weak-sensitive-data-hashing`` query mislabels these tokens as
|
||||
"password" sensitive data and then demands a KDF from its
|
||||
allowlist (Argon2 / scrypt / bcrypt / PBKDF2). Per the query's
|
||||
own recommendation page we use PBKDF2. The persistent salt is
|
||||
still loaded from ``app_secrets`` so an attacker dumping the
|
||||
``api_keys`` table alone cannot derive hashes for candidate
|
||||
tokens without also obtaining the salt row.
|
||||
The slow KDF here is *only* a CodeQL appeasement, not a crypto
|
||||
requirement: API keys are random 128-bit tokens (``secrets.token_hex``),
|
||||
so brute force against 2^128 is infeasible regardless of hash speed.
|
||||
CodeQL's ``py/weak-sensitive-data-hashing`` query mislabels them as
|
||||
"password" data and demands a KDF from its allowlist (Argon2 / scrypt /
|
||||
bcrypt / PBKDF2); we use PBKDF2 per its recommendation page. The salt is
|
||||
still loaded from ``app_secrets`` so dumping the ``api_keys`` table alone
|
||||
can't derive hashes for candidate tokens without the salt row.
|
||||
"""
|
||||
salt = _get_or_create_api_key_pbkdf2_salt()
|
||||
dk = hashlib.pbkdf2_hmac(
|
||||
|
|
|
|||
|
|
@ -2,15 +2,14 @@
|
|||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""
|
||||
Colab-specific helpers for running Unsloth Studio.
|
||||
Uses Colab's built-in proxy - no external tunneling needed!
|
||||
Colab helpers for Unsloth Studio. Uses Colab's built-in proxy.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
# Fix for Anaconda/conda-forge Python: seed platform._sys_version_cache before
|
||||
# any library imports that trigger attrs -> rich -> structlog -> platform crash.
|
||||
# Anaconda/conda-forge Python: seed platform._sys_version_cache before any
|
||||
# import that triggers attrs -> rich -> structlog -> platform crash.
|
||||
# See: https://github.com/python/cpython/issues/102396
|
||||
_backend_dir = str(Path(__file__).parent)
|
||||
if _backend_dir not in sys.path:
|
||||
|
|
@ -25,11 +24,10 @@ logger = get_logger(__name__)
|
|||
|
||||
def get_colab_url(port: int = 8888) -> str:
|
||||
"""
|
||||
Get the actual Colab proxy URL for a port.
|
||||
Get the Colab proxy URL for a port.
|
||||
|
||||
Retries up to 3 times and validates that the result is a real HTTPS Colab
|
||||
URL before returning. Falls back to http://localhost:{port} only when all
|
||||
attempts fail.
|
||||
Retries up to 3 times, validating the result is a real HTTPS Colab URL.
|
||||
Falls back to http://localhost:{port} only when all attempts fail.
|
||||
"""
|
||||
import time as _time
|
||||
|
||||
|
|
@ -43,7 +41,7 @@ def get_colab_url(port: int = 8888) -> str:
|
|||
for attempt in range(3):
|
||||
try:
|
||||
url = eval_js(f"google.colab.kernel.proxyPort({port})", timeout_sec = 10)
|
||||
# A valid Colab proxy URL starts with https:// and embeds the port.
|
||||
# Valid Colab proxy URL starts with https:// and embeds the port.
|
||||
if url and isinstance(url, str) and url.startswith("https://") and str(port) in url:
|
||||
return url.rstrip("/")
|
||||
except Exception as e:
|
||||
|
|
@ -61,16 +59,16 @@ def get_colab_url(port: int = 8888) -> str:
|
|||
def show_link(port: int = 8888, *, _url: "str | None" = None):
|
||||
"""Display a styled clickable link to the UI.
|
||||
|
||||
*_url* is an optional pre-fetched Colab proxy URL. When omitted,
|
||||
``get_colab_url(port)`` is called internally. Pass it from
|
||||
``_show_and_embed`` to avoid a second ``eval_js`` round-trip.
|
||||
*_url* is an optional pre-fetched Colab proxy URL; when omitted,
|
||||
``get_colab_url(port)`` is called. Pass it from ``_show_and_embed`` to
|
||||
avoid a second ``eval_js`` round-trip.
|
||||
"""
|
||||
from IPython.display import display, HTML
|
||||
|
||||
url = _url if _url is not None else get_colab_url(port)
|
||||
|
||||
# Build a truncated display URL. Wrap in try/except so an unexpected URL
|
||||
# shape never prevents the link from rendering.
|
||||
# Truncated display URL. try/except so an unexpected URL shape never
|
||||
# prevents the link from rendering.
|
||||
try:
|
||||
port_prefix = f"{port}-"
|
||||
idx = url.index(port_prefix)
|
||||
|
|
@ -79,8 +77,7 @@ def show_link(port: int = 8888, *, _url: "str | None" = None):
|
|||
except (ValueError, IndexError):
|
||||
short_url = url
|
||||
|
||||
# Also emit a plain-text line so the URL is visible even if HTML display
|
||||
# is suppressed or fails.
|
||||
# Plain-text line so the URL is visible even if HTML display fails.
|
||||
logger.info(f"🌐 Unsloth Studio URL: {url}")
|
||||
|
||||
html = f"""
|
||||
|
|
@ -123,12 +120,10 @@ def _is_studio_healthy(port: int, timeout: float = 2.0) -> bool:
|
|||
def _show_and_embed(port: int):
|
||||
"""Embed the Studio inline for *port* with a branded header bar.
|
||||
|
||||
Fetches the Colab proxy URL once (registering the port with Colab's
|
||||
reverse-proxy at the same time) then renders a header bar + full-height
|
||||
iframe as a single HTML block.
|
||||
|
||||
Falls back to ``serve_kernel_port_as_iframe`` if ``IPython.display.HTML``
|
||||
is unavailable for any reason.
|
||||
Fetches the Colab proxy URL once (also registering the port with Colab's
|
||||
reverse-proxy) then renders a header bar + full-height iframe as one HTML
|
||||
block. Falls back to ``serve_kernel_port_as_iframe`` if
|
||||
``IPython.display.HTML`` is unavailable.
|
||||
"""
|
||||
url = get_colab_url(port)
|
||||
logger.info(f"🌐 Unsloth Studio URL: {url}")
|
||||
|
|
@ -138,7 +133,7 @@ def _show_and_embed(port: int):
|
|||
|
||||
iframe_id = f"unsloth-studio-{port}"
|
||||
|
||||
# Truncated URL shown in the header — best-effort, falls back to full URL.
|
||||
# Truncated header URL — best-effort, falls back to full URL.
|
||||
try:
|
||||
port_prefix = f"{port}-"
|
||||
idx = url.index(port_prefix)
|
||||
|
|
@ -188,8 +183,8 @@ def start(port: int = 8888):
|
|||
logger.info("🦥 Starting Unsloth Studio...")
|
||||
|
||||
# --- Fast path: Studio is already running (cell re-run) ---
|
||||
# Re-launching would either collide on the port or silently shift to a new
|
||||
# port and confuse the user. Just re-show the link and iframe instead.
|
||||
# Re-launching would collide on the port or silently shift to a new one.
|
||||
# Just re-show the link and iframe instead.
|
||||
if _is_studio_healthy(port):
|
||||
logger.info(f" Studio is already running on port {port} — reusing existing server.")
|
||||
_show_and_embed(port)
|
||||
|
|
@ -222,16 +217,16 @@ def start(port: int = 8888):
|
|||
logger.error(f"❌ Unsloth Studio failed to start: {exc}")
|
||||
return
|
||||
|
||||
# run_server auto-increments the port when the requested one is already in
|
||||
# use (e.g. Jupyter occupying 8888). Read back the actual bound port so the
|
||||
# Colab proxy URL and iframe always point at the right place.
|
||||
# run_server auto-increments the port if the requested one is in use (e.g.
|
||||
# Jupyter on 8888). Read back the bound port so the Colab proxy URL and
|
||||
# iframe point at the right place.
|
||||
actual_port: int = getattr(getattr(app, "state", None), "server_port", None) or port
|
||||
|
||||
logger.info(f" Server started on port {actual_port}!")
|
||||
|
||||
# Poll health endpoint to confirm the server is truly reachable before
|
||||
# showing the link and registering the iframe — avoids the race where
|
||||
# ready_event fires but the process hasn't finished binding.
|
||||
# Poll health endpoint to confirm reachability before showing the link and
|
||||
# registering the iframe — avoids the race where ready_event fires but the
|
||||
# process hasn't finished binding.
|
||||
import urllib.request
|
||||
|
||||
server_ready = False
|
||||
|
|
@ -252,9 +247,9 @@ def start(port: int = 8888):
|
|||
|
||||
_show_and_embed(actual_port)
|
||||
|
||||
# Keep kernel alive so the daemon server thread stays running.
|
||||
# Handle KeyboardInterrupt cleanly so the user gets a readable message
|
||||
# rather than a raw traceback when they interrupt the cell.
|
||||
# Keep kernel alive so the daemon server thread stays running. Handle
|
||||
# KeyboardInterrupt cleanly so interrupting the cell gives a readable
|
||||
# message, not a raw traceback.
|
||||
try:
|
||||
for _ in range(10000):
|
||||
time.sleep(300)
|
||||
|
|
|
|||
|
|
@ -4,18 +4,16 @@
|
|||
"""
|
||||
Unified core module for Unsloth backend
|
||||
|
||||
Imports are LAZY (via __getattr__) so that training subprocesses can
|
||||
import core.training.worker without pulling in heavy ML dependencies
|
||||
like unsloth, transformers, or torch before the version activation
|
||||
code has a chance to run.
|
||||
Imports are LAZY (via __getattr__) so training subprocesses can import
|
||||
core.training.worker without pulling in heavy ML deps (unsloth, transformers,
|
||||
torch) before the version-activation code runs.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure the backend directory is on sys.path so that bare "from utils.*"
|
||||
# imports used throughout the backend work when core is imported as a package
|
||||
# (e.g. from the CLI: "from studio.backend.core import ModelConfig").
|
||||
# Put the backend dir on sys.path so bare "from utils.*" imports work when core
|
||||
# is imported as a package (e.g. CLI: "from studio.backend.core import ModelConfig").
|
||||
_backend_dir = str(Path(__file__).resolve().parent.parent)
|
||||
if _backend_dir not in sys.path:
|
||||
sys.path.insert(0, _backend_dir)
|
||||
|
|
@ -69,7 +67,7 @@ def __getattr__(name):
|
|||
globals()["TrainingProgress"] = TrainingProgress
|
||||
return globals()[name]
|
||||
|
||||
# Config (from utils.models)
|
||||
# Config (utils.models)
|
||||
if name in (
|
||||
"is_vision_model",
|
||||
"ModelConfig",
|
||||
|
|
|
|||
|
|
@ -6,15 +6,14 @@
|
|||
torchao (pulled in by transformers.quantizers) imports
|
||||
torch.distributed._functional_collectives at module level, which imports
|
||||
distributed_c10d.py unconditionally — that file crashes on Windows ROCm because
|
||||
torch._C._distributed_c10d (the RCCL backend) is absent.
|
||||
torch/distributed/__init__.py itself is guarded by `if is_available()` so
|
||||
`import torch.distributed` alone is safe; the crash only comes via torchao's
|
||||
import chain. Stubbing torchao short-circuits it entirely.
|
||||
torch._C._distributed_c10d (the RCCL backend) is absent. `import
|
||||
torch.distributed` alone is safe (guarded by `if is_available()`); the crash
|
||||
only comes via torchao's import chain, so stubbing torchao short-circuits it.
|
||||
_StubSubpackageFinder handles any depth of torchao.xxx.yyy imports.
|
||||
|
||||
This logic used to be duplicated inline inside run_export_process() and
|
||||
run_training_process(); it now lives here so both worker subprocesses call the
|
||||
single `install_torchao_windows_rocm_stub()` entrypoint before importing
|
||||
Previously duplicated inline in run_export_process() and run_training_process();
|
||||
now both worker subprocesses call the single
|
||||
`install_torchao_windows_rocm_stub()` entrypoint before importing
|
||||
transformers / unsloth_zoo.
|
||||
"""
|
||||
|
||||
|
|
@ -28,9 +27,9 @@ import importlib.machinery
|
|||
_STUB_SENTINEL = object()
|
||||
|
||||
|
||||
# Metaclass for stub types so that isinstance(x, StubClass) returns False
|
||||
# instead of raising TypeError ("arg 2 must be a type").
|
||||
# peft/tuners/lora/torchao.py does:
|
||||
# Metaclass for stub types so isinstance(x, StubClass) returns False instead
|
||||
# of raising TypeError ("arg 2 must be a type"). peft/tuners/lora/torchao.py
|
||||
# does:
|
||||
# from torchao.dtypes import AffineQuantizedTensor, LinearActivationQuantizedTensor
|
||||
# isinstance(weight, (AffineQuantizedTensor, LinearActivationQuantizedTensor))
|
||||
# If those names resolve to stub modules rather than types, isinstance() raises.
|
||||
|
|
@ -71,8 +70,8 @@ def _make_mod_stub(mod_name):
|
|||
):
|
||||
if attr.startswith("__"):
|
||||
raise AttributeError(attr)
|
||||
# Return a stub CLASS (not a module) so that isinstance(x, attr)
|
||||
# works and returns False instead of raising TypeError.
|
||||
# Return a stub CLASS (not a module) so isinstance(x, attr) returns
|
||||
# False instead of raising TypeError.
|
||||
child = _make_stub_type(f"{_n}.{attr}")
|
||||
setattr(_m, attr, child)
|
||||
return child
|
||||
|
|
@ -114,14 +113,13 @@ class _StubSubpackageFinder(importlib.abc.MetaPathFinder):
|
|||
def install_torchao_windows_rocm_stub() -> None:
|
||||
"""Pre-stub torchao on Windows ROCm so transformers/peft imports don't crash.
|
||||
|
||||
No-op on every other platform (Windows CUDA included — there torchao is real
|
||||
and shadowing it would break torchao-based quantization paths). Must run
|
||||
before any import of transformers / unsloth_zoo. Safe to call once per worker
|
||||
process.
|
||||
No-op on every other platform (incl. Windows CUDA — there torchao is real
|
||||
and shadowing it would break torchao quantization paths). Must run before
|
||||
any import of transformers / unsloth_zoo. Safe to call once per worker.
|
||||
"""
|
||||
# Gate on the active torch runtime, not env-var presence -- HIP_PATH /
|
||||
# ROCM_PATH stay set after a user installs the HIP SDK and reverts to a
|
||||
# CUDA torch wheel. AMD SDK / Radeon ROCm wheels may not set torch.version.hip
|
||||
# ROCM_PATH stay set after installing the HIP SDK and reverting to a CUDA
|
||||
# torch wheel. AMD SDK / Radeon ROCm wheels may not set torch.version.hip
|
||||
# but still encode "rocm" in torch.__version__, so accept either.
|
||||
_is_win32_rocm = False
|
||||
if sys.platform == "win32":
|
||||
|
|
@ -135,8 +133,8 @@ def install_torchao_windows_rocm_stub() -> None:
|
|||
except Exception:
|
||||
pass
|
||||
if _is_win32_rocm:
|
||||
# Register the finder only on Windows ROCm -- on other platforms there
|
||||
# are no stub modules seeded, so appending is a pure accumulation.
|
||||
# Register the finder only on Windows ROCm -- elsewhere no stub modules
|
||||
# are seeded, so appending would be pure accumulation.
|
||||
sys.meta_path.append(_StubSubpackageFinder())
|
||||
# Seed torchao top-level + key submodules; the finder handles the rest.
|
||||
for _tao_name in (
|
||||
|
|
|
|||
|
|
@ -95,8 +95,8 @@ def publish_recipe_dataset(
|
|||
tags = None,
|
||||
)
|
||||
card.text = card.text.replace(_DATA_DESIGNER_FOOTER, _UNSLOTH_STUDIO_FOOTER)
|
||||
# Data Designer currently drops the explicit token when pushing the
|
||||
# dataset card. Push it ourselves so auth stays request-local.
|
||||
# Data Designer drops the explicit token when pushing the dataset
|
||||
# card, so push it ourselves to keep auth request-local.
|
||||
card.push_to_hub(repo_id, token = hf_token, repo_type = "dataset")
|
||||
|
||||
client._upload_main_dataset_files(
|
||||
|
|
|
|||
|
|
@ -134,8 +134,8 @@ class JobManager:
|
|||
|
||||
``internal_api_key_id`` is the row id of a workflow-scoped
|
||||
sk-unsloth-* key minted by the route layer for local providers.
|
||||
JobManager revokes it when the job reaches a terminal state so the
|
||||
key's live window is no longer than the run.
|
||||
JobManager revokes it on terminal state so the key's live window is
|
||||
no longer than the run.
|
||||
"""
|
||||
llm_columns = recipe.get("columns") or []
|
||||
llm_column_count = 0
|
||||
|
|
@ -202,7 +202,7 @@ class JobManager:
|
|||
return True
|
||||
|
||||
def get_status(self, job_id: str) -> dict | None:
|
||||
"""UI friendly snapshot that we need. Alternative to sse kinda of and structured"""
|
||||
"""UI-friendly structured snapshot; an alternative to SSE."""
|
||||
with self._lock:
|
||||
if self._job is None or self._job.job_id != job_id:
|
||||
return None
|
||||
|
|
@ -537,9 +537,9 @@ class JobManager:
|
|||
def _retire_workflow_key(self, job: Job) -> None:
|
||||
"""Revoke the workflow-scoped sk-unsloth-* key, if one was minted.
|
||||
|
||||
Best-effort: revocation failures are swallowed. The key would
|
||||
expire on its own after 24h, so a missed revoke is a latency
|
||||
concern, not a correctness one.
|
||||
Best-effort: revocation failures are swallowed. The key expires on
|
||||
its own after 24h, so a missed revoke is a latency concern, not a
|
||||
correctness one.
|
||||
"""
|
||||
key_id = getattr(job, "internal_api_key_id", None)
|
||||
if not key_id:
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ class ParsedUpdate:
|
|||
source_progress: SourceProgress | None = None
|
||||
|
||||
|
||||
# kinda of a bummber but currently only option, Best effort parser from data-designer logs -> structured status for UI.
|
||||
# Best-effort parser from data-designer logs -> structured status for UI.
|
||||
_RE_SAMPLERS = re.compile(
|
||||
r"Preparing samplers to generate (?P<rows>\d+) records across (?P<cols>\d+) columns"
|
||||
)
|
||||
|
|
@ -327,7 +327,7 @@ def apply_update(job: Job, update: ParsedUpdate) -> None:
|
|||
_apply_source_progress(job, update.source_progress)
|
||||
|
||||
if update.stage in USAGE_RESET_STAGES:
|
||||
# usage summary is a short block so we reset once we move into the next stage.
|
||||
# Usage summary is a short block; reset on entering the next stage.
|
||||
job._in_usage_summary = False
|
||||
|
||||
if update.usage_section_start is not None:
|
||||
|
|
|
|||
|
|
@ -91,8 +91,8 @@ class Job:
|
|||
source_progress_estimated_total: int | None = None
|
||||
completed_columns: list[str] = field(default_factory = list)
|
||||
# Id of the internal sk-unsloth-* API key minted for a local-model
|
||||
# workflow. Revoked when the job terminates so the key's live window
|
||||
# matches the run rather than its 24h TTL.
|
||||
# workflow. Revoked when the job ends so the key's window matches the
|
||||
# run rather than its 24h TTL.
|
||||
internal_api_key_id: int | None = None
|
||||
_current_usage_model: str | None = None
|
||||
_in_usage_summary: bool = False
|
||||
|
|
|
|||
|
|
@ -73,10 +73,7 @@ def _build_dataset_name(*, run_name: str | None, job_id: str, artifact_root: Pat
|
|||
|
||||
|
||||
def run_job_process(*, event_queue, recipe: dict[str, Any], run: dict[str, Any]) -> None:
|
||||
"""
|
||||
Subprocess entrypoint.
|
||||
Sends events to `event_queue`.
|
||||
"""
|
||||
"""Subprocess entrypoint. Sends events to `event_queue`."""
|
||||
import os
|
||||
|
||||
os.environ["PYTHONWARNINGS"] = "ignore" # Suppress warnings at C-level before imports
|
||||
|
|
@ -115,8 +112,8 @@ def run_job_process(*, event_queue, recipe: dict[str, Any], run: dict[str, Any])
|
|||
builder = build_config_builder(recipe)
|
||||
designer = create_data_designer(recipe, artifact_path = str(_ARTIFACT_ROOT))
|
||||
|
||||
# DataDesigner configures root logging in DataDesigner.__init__.
|
||||
# Attach queue logger directly to `data_designer` so parser events survive root resets.
|
||||
# DataDesigner configures root logging in __init__. Attach the queue
|
||||
# logger to `data_designer` directly so parser events survive root resets.
|
||||
handler = _QueueLogHandler(event_queue)
|
||||
handler.setLevel(logging.INFO)
|
||||
for logger_name in (
|
||||
|
|
|
|||
|
|
@ -167,9 +167,10 @@ def _validate_recipe_runtime_support(recipe: dict[str, Any], model_providers: li
|
|||
def build_mcp_providers(recipe: dict[str, Any]) -> list:
|
||||
from data_designer.config.mcp import LocalStdioMCPProvider, MCPProvider # pyright: ignore[reportMissingImports]
|
||||
|
||||
# Same gate as the chat MCP path: stdio providers spawn a local subprocess,
|
||||
# so only build them when this host allows it (desktop / explicit opt-in).
|
||||
# Skip them otherwise so a recipe carried onto a hosted host cannot spawn.
|
||||
# Same gate as the chat MCP path: stdio providers spawn a local
|
||||
# subprocess, so build them only when this host allows it (desktop /
|
||||
# explicit opt-in). Otherwise a recipe carried onto a hosted host
|
||||
# cannot spawn.
|
||||
from core.inference.mcp_client import stdio_mcp_enabled
|
||||
|
||||
stdio_allowed = stdio_mcp_enabled()
|
||||
|
|
@ -258,7 +259,7 @@ def build_config_builder(recipe: dict[str, Any]):
|
|||
)
|
||||
|
||||
# DataDesignerConfigBuilder.from_config currently skips processors.
|
||||
# Re-attach explicitly so drop_columns/schema_transform survive API payload.
|
||||
# Re-attach so drop_columns/schema_transform survive the API payload.
|
||||
for processor in recipe_core.get("processors") or []:
|
||||
if not isinstance(processor, dict):
|
||||
continue
|
||||
|
|
@ -283,8 +284,8 @@ def create_data_designer(recipe: dict[str, Any], *, artifact_path: str | None =
|
|||
_validate_recipe_runtime_support(recipe, model_providers)
|
||||
|
||||
# DataDesigner requires at least one model provider in its registry even
|
||||
# when the pipeline contains no LLM columns. Supply a lightweight stub
|
||||
# so sampler/expression-only recipes can run without a real provider.
|
||||
# when the pipeline has no LLM columns. Supply a lightweight stub so
|
||||
# sampler/expression-only recipes can run without a real provider.
|
||||
if not model_providers:
|
||||
from data_designer.config.models import ModelProvider # pyright: ignore[reportMissingImports]
|
||||
model_providers = [
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@
|
|||
Export submodule - Model export operations
|
||||
|
||||
The default get_export_backend() returns an ExportOrchestrator that
|
||||
delegates to a subprocess. The original ExportBackend runs inside
|
||||
the subprocess and can be imported directly from .export when needed.
|
||||
delegates to a subprocess. The original ExportBackend runs inside the
|
||||
subprocess and can be imported directly from .export when needed.
|
||||
"""
|
||||
|
||||
from .orchestrator import ExportOrchestrator, get_export_backend
|
||||
|
|
|
|||
|
|
@ -2,9 +2,7 @@
|
|||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
# backend/export.py
|
||||
"""
|
||||
Export backend - handles model exporting in various formats
|
||||
"""
|
||||
"""Export backend - exports models in various formats."""
|
||||
|
||||
import glob
|
||||
import json
|
||||
|
|
@ -46,10 +44,10 @@ def _is_wsl():
|
|||
def _apply_wsl_sudo_patch():
|
||||
"""On WSL, monkey-patch do_we_need_sudo() to return False.
|
||||
|
||||
WSL doesn't have passwordless sudo, and do_we_need_sudo() runs
|
||||
`sudo apt-get update` which hangs waiting for a stdin password
|
||||
inside a non-interactive subprocess. setup.sh pre-installs the
|
||||
build dependencies on WSL, so sudo is not needed at runtime.
|
||||
WSL lacks passwordless sudo, and do_we_need_sudo() runs
|
||||
`sudo apt-get update`, which hangs on a stdin password in a
|
||||
non-interactive subprocess. setup.sh pre-installs the build deps on
|
||||
WSL, so sudo isn't needed at runtime.
|
||||
"""
|
||||
if not _is_wsl():
|
||||
return
|
||||
|
|
@ -110,7 +108,7 @@ class ExportBackend:
|
|||
try:
|
||||
logger.info("Starting memory cleanup...")
|
||||
|
||||
# Unload all models from inference backend
|
||||
# Unload all inference-backend models
|
||||
model_names = list(self.inference_backend.models.keys())
|
||||
for model_name in model_names:
|
||||
self.inference_backend.unload_model(model_name)
|
||||
|
|
@ -121,7 +119,7 @@ class ExportBackend:
|
|||
self.current_checkpoint = None
|
||||
self._audio_type = None
|
||||
|
||||
# Clear GPU memory cache (handles gc + backend-specific cleanup)
|
||||
# Clear GPU cache (handles gc + backend-specific cleanup)
|
||||
clear_gpu_cache()
|
||||
|
||||
logger.info("Memory cleanup completed successfully")
|
||||
|
|
@ -137,8 +135,7 @@ class ExportBackend:
|
|||
"""
|
||||
Scan outputs folder for training runs and their checkpoints.
|
||||
|
||||
Returns:
|
||||
List of tuples: [(model_name, [(display_name, checkpoint_path), ...]), ...]
|
||||
Returns: [(model_name, [(display_name, checkpoint_path), ...]), ...]
|
||||
"""
|
||||
from utils.models.checkpoints import scan_checkpoints
|
||||
return scan_checkpoints(outputs_dir = outputs_dir)
|
||||
|
|
@ -159,12 +156,12 @@ class ExportBackend:
|
|||
try:
|
||||
logger.info(f"Loading checkpoint: {checkpoint_path}")
|
||||
|
||||
# First, cleanup existing models
|
||||
# Cleanup existing models first
|
||||
self.cleanup_memory()
|
||||
|
||||
checkpoint_path_obj = Path(checkpoint_path)
|
||||
|
||||
# Determine the model identity for type detection
|
||||
# Model identity for type detection
|
||||
adapter_config = checkpoint_path_obj / "adapter_config.json"
|
||||
base_model = None
|
||||
if adapter_config.exists():
|
||||
|
|
@ -246,7 +243,7 @@ class ExportBackend:
|
|||
load_in_4bit = load_in_4bit,
|
||||
trust_remote_code = trust_remote_code,
|
||||
)
|
||||
tokenizer = processor # For vision models, processor acts as tokenizer
|
||||
tokenizer = processor # vision: processor acts as tokenizer
|
||||
|
||||
else:
|
||||
logger.info("Loading as text model...")
|
||||
|
|
@ -258,14 +255,13 @@ class ExportBackend:
|
|||
trust_remote_code = trust_remote_code,
|
||||
)
|
||||
|
||||
# Check if PEFT / LoRA model
|
||||
# Detect PEFT / LoRA model
|
||||
if _IS_MLX:
|
||||
# MLX doesn't use PeftModel — detect LoRA via adapter_config.json
|
||||
self.is_peft = adapter_config.exists()
|
||||
else:
|
||||
self.is_peft = isinstance(model, (PeftModel, PeftModelForCausalLM))
|
||||
|
||||
# Store loaded model
|
||||
self.current_model = model
|
||||
self.current_tokenizer = tokenizer
|
||||
self.current_checkpoint = checkpoint_path
|
||||
|
|
@ -459,7 +455,7 @@ class ExportBackend:
|
|||
|
||||
if _IS_MLX:
|
||||
# MLX: save_pretrained_merged handles non-LoRA models too
|
||||
# (fuse() is a no-op when there are no LoRA layers)
|
||||
# (fuse() is a no-op without LoRA layers)
|
||||
self.current_model.save_pretrained_merged(
|
||||
save_directory,
|
||||
self.current_tokenizer,
|
||||
|
|
@ -509,12 +505,11 @@ class ExportBackend:
|
|||
private = private,
|
||||
)
|
||||
else:
|
||||
# Get base model name from request or model config
|
||||
# Base model name from request or model config
|
||||
base_model = (
|
||||
base_model_id or self.current_model.config._name_or_path or "unknown"
|
||||
)
|
||||
|
||||
# Create repo
|
||||
hf_api = HfApi(token = hf_token)
|
||||
repo_id = PushToHubMixin._create_repo(
|
||||
PushToHubMixin,
|
||||
|
|
@ -524,7 +519,7 @@ class ExportBackend:
|
|||
)
|
||||
username = repo_id.split("/")[0]
|
||||
|
||||
# Create and push model card
|
||||
# Build and push model card
|
||||
content = MODEL_CARD.format(
|
||||
username = username,
|
||||
base_model = base_model,
|
||||
|
|
@ -535,7 +530,7 @@ class ExportBackend:
|
|||
card = ModelCard(content)
|
||||
card.push_to_hub(repo_id, token = hf_token, commit_message = "Unsloth Model Card")
|
||||
|
||||
# Upload model files
|
||||
# Upload files
|
||||
if save_directory:
|
||||
hf_api.upload_folder(
|
||||
folder_path = save_directory,
|
||||
|
|
@ -585,7 +580,7 @@ class ExportBackend:
|
|||
|
||||
output_path: Optional[str] = None
|
||||
try:
|
||||
# Convert quantization method to lowercase for unsloth
|
||||
# unsloth expects lowercase quant method
|
||||
quant_method = quantization_method.lower()
|
||||
|
||||
# Pin convert_hf_to_gguf.py to the same llama.cpp ref as the
|
||||
|
|
@ -613,27 +608,26 @@ class ExportBackend:
|
|||
# Save locally if requested
|
||||
if save_directory:
|
||||
save_directory = str(resolve_export_dir(save_directory))
|
||||
# Resolve to absolute path so unsloth's relative-path internals
|
||||
# Use absolute path so unsloth's relative-path internals
|
||||
# (check_llama_cpp, use_local_gguf, _download_convert_hf_to_gguf)
|
||||
# all resolve against the repo root cwd, NOT the export directory.
|
||||
# resolve against the repo root cwd, NOT the export directory.
|
||||
abs_save_dir = os.path.abspath(save_directory)
|
||||
logger.info(f"Saving GGUF model locally to: {abs_save_dir}")
|
||||
|
||||
# Create the directory if it doesn't exist
|
||||
ensure_dir(Path(abs_save_dir))
|
||||
|
||||
# On WSL, patch out sudo check before llama.cpp build
|
||||
_apply_wsl_sudo_patch()
|
||||
|
||||
# Snapshot existing .gguf files in cwd before conversion.
|
||||
# unsloth's convert_to_gguf writes output files relative to
|
||||
# cwd (repo root), so we diff afterwards and relocate them.
|
||||
# Snapshot existing .gguf files in cwd before conversion;
|
||||
# unsloth's convert_to_gguf writes output relative to cwd
|
||||
# (repo root), so we diff afterwards and relocate.
|
||||
cwd = os.getcwd()
|
||||
pre_existing_ggufs = set(glob.glob(os.path.join(cwd, "*.gguf")))
|
||||
|
||||
# Pass absolute path — no os.chdir needed.
|
||||
# unsloth saves intermediate HF model files into model_save_path.
|
||||
# unsloth-zoo's check_llama_cpp() uses ~/.unsloth/llama.cpp by default.
|
||||
# Absolute path — no os.chdir needed. unsloth saves intermediate
|
||||
# HF model files into model_save_path; unsloth-zoo's
|
||||
# check_llama_cpp() uses ~/.unsloth/llama.cpp by default.
|
||||
model_save_path = os.path.join(abs_save_dir, "model")
|
||||
self.current_model.save_pretrained_gguf(
|
||||
model_save_path,
|
||||
|
|
@ -642,17 +636,17 @@ class ExportBackend:
|
|||
)
|
||||
|
||||
# Relocate GGUF artifacts into the export directory.
|
||||
# convert_to_gguf writes .gguf files to cwd (repo root)
|
||||
# because --outfile is a relative path like "model.Q4_K_M.gguf".
|
||||
# convert_to_gguf writes .gguf to cwd (repo root) because
|
||||
# --outfile is a relative path like "model.Q4_K_M.gguf".
|
||||
new_ggufs = set(glob.glob(os.path.join(cwd, "*.gguf"))) - pre_existing_ggufs
|
||||
for src in sorted(new_ggufs):
|
||||
dest = os.path.join(abs_save_dir, os.path.basename(src))
|
||||
shutil.move(src, dest)
|
||||
logger.info(f"Relocated GGUF: {os.path.basename(src)} → {abs_save_dir}/")
|
||||
|
||||
# Flatten any .gguf files from subdirectories into abs_save_dir.
|
||||
# Flatten any .gguf from subdirs into abs_save_dir.
|
||||
# save_pretrained_gguf may create subdirs (e.g. model_gguf/)
|
||||
# with a name different from model_save_path.
|
||||
# named differently from model_save_path.
|
||||
for sub in list(Path(abs_save_dir).iterdir()):
|
||||
if not sub.is_dir():
|
||||
continue
|
||||
|
|
@ -660,13 +654,13 @@ class ExportBackend:
|
|||
dest = os.path.join(abs_save_dir, src.name)
|
||||
shutil.move(str(src), dest)
|
||||
logger.info(f"Relocated GGUF: {src.name} → {abs_save_dir}/")
|
||||
# Clean up the subdirectory (intermediate HF files, etc.)
|
||||
# Clean up the subdir (intermediate HF files, etc.)
|
||||
shutil.rmtree(str(sub), ignore_errors = True)
|
||||
logger.info(f"Cleaned up subdirectory: {sub.name}")
|
||||
|
||||
# For non-PEFT models, save_pretrained_gguf redirects to the
|
||||
# checkpoint path, leaving a *_gguf directory in outputs/.
|
||||
# Relocate any GGUFs from there and clean it up.
|
||||
# checkpoint path, leaving a *_gguf dir in outputs/. Relocate
|
||||
# any GGUFs from there and clean it up.
|
||||
if self.current_checkpoint:
|
||||
ckpt = Path(self.current_checkpoint)
|
||||
gguf_dir = ckpt.parent / f"{ckpt.name}_gguf"
|
||||
|
|
@ -686,8 +680,7 @@ class ExportBackend:
|
|||
# Write export metadata so the Chat page can identify the base model
|
||||
self._write_export_metadata(abs_save_dir)
|
||||
|
||||
# Log final file locations (after relocation) so it's clear
|
||||
# where the GGUF files actually ended up.
|
||||
# Log final file locations (post-relocation).
|
||||
final_ggufs = sorted(glob.glob(os.path.join(abs_save_dir, "*.gguf")))
|
||||
logger.info(
|
||||
"GGUF export complete. Final files in %s:\n %s",
|
||||
|
|
|
|||
|
|
@ -1,15 +1,13 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""
|
||||
Export orchestrator — subprocess-based.
|
||||
"""Export orchestrator — subprocess-based.
|
||||
|
||||
Provides the same API as ExportBackend, but delegates all ML work
|
||||
to a persistent subprocess. The subprocess is spawned on first checkpoint
|
||||
load and stays alive for subsequent export operations.
|
||||
Same API as ExportBackend, but delegates all ML work to a persistent
|
||||
subprocess spawned on first checkpoint load and reused for later exports.
|
||||
|
||||
When switching between checkpoints that need different transformers versions,
|
||||
the old subprocess is killed and a new one is spawned with the correct version.
|
||||
When switching between checkpoints needing different transformers
|
||||
versions, the old subprocess is killed and a new one spawned.
|
||||
|
||||
Pattern follows core/inference/orchestrator.py.
|
||||
"""
|
||||
|
|
@ -30,9 +28,9 @@ logger = get_logger(__name__)
|
|||
|
||||
_CTX = mp.get_context("spawn")
|
||||
|
||||
# Maximum number of captured log lines kept in memory per export
|
||||
# orchestrator. Acts as scrollback for the live export log panel in the
|
||||
# UI. 4000 lines is ~1 MB worst-case at 256 chars/line.
|
||||
# Max captured log lines kept in memory per export orchestrator;
|
||||
# scrollback for the live export log panel. 4000 lines is ~1 MB
|
||||
# worst-case at 256 chars/line.
|
||||
_LOG_BUFFER_MAXLEN = 4000
|
||||
|
||||
|
||||
|
|
@ -41,46 +39,44 @@ class ExportOrchestrator:
|
|||
Export backend orchestrator — subprocess-based.
|
||||
|
||||
Exposes the same API surface as ExportBackend so routes/export.py
|
||||
needs minimal changes. Internally, all heavy ML operations happen in
|
||||
a persistent subprocess.
|
||||
needs minimal changes. All heavy ML work happens in a persistent
|
||||
subprocess.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
# Subprocess state
|
||||
# Subprocess state.
|
||||
self._proc: Optional[mp.Process] = None
|
||||
self._cmd_queue: Any = None
|
||||
self._resp_queue: Any = None
|
||||
# Serializes export operations (load_checkpoint, export_*,
|
||||
# cleanup) so concurrent HTTP requests can never interleave
|
||||
# commands on the subprocess queue. Previously unused.
|
||||
# Serializes export ops (load_checkpoint, export_*, cleanup) so
|
||||
# concurrent HTTP requests can't interleave commands on the
|
||||
# subprocess queue.
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# Local state mirrors (updated from subprocess responses)
|
||||
# Local state mirrors (updated from subprocess responses).
|
||||
self.current_checkpoint: Optional[str] = None
|
||||
self.is_vision: bool = False
|
||||
self.is_peft: bool = False
|
||||
|
||||
# ── Live log capture ─────────────────────────────────────
|
||||
# Thread-safe ring buffer of log lines forwarded from the
|
||||
# worker subprocess. Powers the GET /api/export/logs/stream
|
||||
# SSE endpoint that the export dialog consumes.
|
||||
# Thread-safe ring buffer of log lines from the worker
|
||||
# subprocess. Powers the GET /api/export/logs/stream SSE
|
||||
# endpoint the export dialog consumes.
|
||||
self._log_buffer: Deque[Dict[str, Any]] = deque(maxlen = _LOG_BUFFER_MAXLEN)
|
||||
self._log_lock = threading.Lock()
|
||||
# Monotonically increasing sequence number. Never reset across
|
||||
# operations, so SSE clients can use it as a stable cursor even
|
||||
# if clear_logs() is called mid-session.
|
||||
# Monotonic sequence number. Never reset across operations, so
|
||||
# SSE clients can use it as a stable cursor even if clear_logs()
|
||||
# runs mid-session.
|
||||
self._log_seq: int = 0
|
||||
# Snapshot of _log_seq captured at the start of the current run
|
||||
# (updated by clear_logs()). The SSE endpoint defaults its
|
||||
# cursor to this value so a client that connects AFTER the
|
||||
# worker has already emitted its first lines still sees the
|
||||
# full run. Every line appended during the current run has seq
|
||||
# strictly greater than _run_start_seq, and every line from
|
||||
# prior runs has seq less than or equal to it.
|
||||
# Snapshot of _log_seq at the start of the current run (set by
|
||||
# clear_logs()). The SSE endpoint defaults its cursor here so a
|
||||
# client connecting AFTER the worker's first lines still sees the
|
||||
# full run. Lines in the current run have seq > _run_start_seq;
|
||||
# prior-run lines have seq <= it.
|
||||
self._run_start_seq: int = 0
|
||||
# True while an export operation (load/export/cleanup) is
|
||||
# running. The SSE endpoint ends the stream 1 second after
|
||||
# this flips back to False to drain any trailing log lines.
|
||||
# True while an export op (load/export/cleanup) is running. The
|
||||
# SSE endpoint ends the stream 1s after this flips False to drain
|
||||
# trailing log lines.
|
||||
self._export_active: bool = False
|
||||
|
||||
atexit.register(self._cleanup)
|
||||
|
|
@ -91,12 +87,11 @@ class ExportOrchestrator:
|
|||
# ------------------------------------------------------------------
|
||||
|
||||
def _append_log(self, entry: Dict[str, Any]) -> None:
|
||||
"""Append a log line from the worker subprocess to the buffer.
|
||||
"""Append a worker-subprocess log line to the buffer.
|
||||
|
||||
Entries look like {"type": "log", "stream": "stdout"|"stderr",
|
||||
"line": "...", "ts": ...}. Each is stamped with a monotonic
|
||||
seq number before it lands in the buffer so SSE clients can
|
||||
cursor through new lines.
|
||||
"line": "...", "ts": ...}. Each gets a monotonic seq number so
|
||||
SSE clients can cursor through new lines.
|
||||
"""
|
||||
line = entry.get("line")
|
||||
if not line:
|
||||
|
|
@ -113,17 +108,16 @@ class ExportOrchestrator:
|
|||
)
|
||||
|
||||
def clear_logs(self) -> None:
|
||||
"""Drop any buffered log lines from a previous operation.
|
||||
"""Drop buffered log lines from a previous operation.
|
||||
|
||||
Called at the start of each export op so the UI shows only the
|
||||
output of the current run. The seq counter is NOT reset, so an
|
||||
SSE client that captured the cursor before clear_logs() will
|
||||
still see new lines (with strictly greater seq numbers).
|
||||
current run. The seq counter is NOT reset, so an SSE client that
|
||||
captured the cursor before clear_logs() still sees new lines
|
||||
(with strictly greater seq).
|
||||
|
||||
Also snapshots the current seq into ``_run_start_seq`` so the
|
||||
SSE endpoint can anchor its default cursor at the start of
|
||||
this run. Anything appended after this call has seq strictly
|
||||
greater than the snapshot and is reachable via
|
||||
Also snapshots the current seq into ``_run_start_seq`` so the SSE
|
||||
endpoint can anchor its default cursor at this run's start.
|
||||
Anything appended after has seq > the snapshot, reachable via
|
||||
``get_logs_since(get_run_start_seq())``.
|
||||
"""
|
||||
with self._log_lock:
|
||||
|
|
@ -144,11 +138,11 @@ class ExportOrchestrator:
|
|||
return self._log_seq
|
||||
|
||||
def get_run_start_seq(self) -> int:
|
||||
"""Return the seq value captured at the start of the current run.
|
||||
"""Return the seq captured at the start of the current run.
|
||||
|
||||
The SSE endpoint uses this as the default cursor so a client
|
||||
that connects AFTER the worker has already started emitting
|
||||
output still sees every line from the current run.
|
||||
connecting AFTER the worker started emitting still sees every
|
||||
line from the current run.
|
||||
"""
|
||||
with self._log_lock:
|
||||
return self._run_start_seq
|
||||
|
|
@ -193,22 +187,22 @@ class ExportOrchestrator:
|
|||
self._proc = None
|
||||
return
|
||||
|
||||
# 1. Drain stale responses
|
||||
# 1. Drain stale responses.
|
||||
self._drain_queue()
|
||||
|
||||
# 2. Send shutdown command
|
||||
# 2. Send shutdown command.
|
||||
try:
|
||||
self._cmd_queue.put({"type": "shutdown"})
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
|
||||
# 3. Wait for graceful shutdown
|
||||
# 3. Wait for graceful shutdown.
|
||||
try:
|
||||
self._proc.join(timeout = timeout)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 4. Force kill if still alive
|
||||
# 4. Force kill if still alive.
|
||||
if self._proc is not None and self._proc.is_alive():
|
||||
logger.warning("Export subprocess did not exit gracefully, terminating")
|
||||
try:
|
||||
|
|
@ -268,9 +262,8 @@ class ExportOrchestrator:
|
|||
) -> dict:
|
||||
"""Block until a response of the expected type arrives.
|
||||
|
||||
Export operations can take a very long time — GGUF conversion for
|
||||
large models (30B+) easily takes 20-30 minutes. Default timeout
|
||||
is 1 hour.
|
||||
Export ops can take a long time — GGUF conversion for large
|
||||
models (30B+) easily takes 20-30 minutes. Default timeout 1 hour.
|
||||
"""
|
||||
deadline = time.monotonic() + timeout
|
||||
|
||||
|
|
@ -279,7 +272,7 @@ class ExportOrchestrator:
|
|||
resp = self._read_resp(timeout = min(remaining, 2.0))
|
||||
|
||||
if resp is None:
|
||||
# Check subprocess health
|
||||
# Check subprocess health.
|
||||
if not self._ensure_subprocess_alive():
|
||||
raise RuntimeError("Export subprocess crashed during wait")
|
||||
continue
|
||||
|
|
@ -294,17 +287,16 @@ class ExportOrchestrator:
|
|||
raise RuntimeError(f"Subprocess error: {error_msg}")
|
||||
|
||||
if rtype == "log":
|
||||
# Forwarded stdout/stderr line from the worker process.
|
||||
# Forwarded stdout/stderr line from the worker.
|
||||
self._append_log(resp)
|
||||
continue
|
||||
|
||||
if rtype == "status":
|
||||
message = resp.get("message", "")
|
||||
logger.info("Export subprocess status: %s", message)
|
||||
# Surface status messages in the live log panel too so
|
||||
# users see high level progress (e.g. "Importing
|
||||
# Unsloth...", "Loading checkpoint: ...") alongside
|
||||
# subprocess output.
|
||||
# Surface status in the live log panel too so users see
|
||||
# high-level progress (e.g. "Importing Unsloth...",
|
||||
# "Loading checkpoint: ...") alongside subprocess output.
|
||||
if message:
|
||||
self._append_log(
|
||||
{
|
||||
|
|
@ -315,7 +307,7 @@ class ExportOrchestrator:
|
|||
)
|
||||
continue
|
||||
|
||||
# Other response types during wait — skip
|
||||
# Other response types during wait — skip.
|
||||
logger.debug(
|
||||
"Skipping response type '%s' while waiting for '%s'",
|
||||
rtype,
|
||||
|
|
@ -362,12 +354,11 @@ class ExportOrchestrator:
|
|||
}
|
||||
|
||||
with self._lock:
|
||||
# Start a fresh log buffer for this operation so the UI
|
||||
# sees only the current run's output.
|
||||
# Fresh log buffer so the UI sees only this run's output.
|
||||
self.clear_logs()
|
||||
self._export_active = True
|
||||
try:
|
||||
# Always kill existing subprocess and spawn fresh.
|
||||
# Always kill any existing subprocess and spawn fresh.
|
||||
if self._ensure_subprocess_alive():
|
||||
self._shutdown_subprocess()
|
||||
elif self._proc is not None:
|
||||
|
|
@ -488,12 +479,11 @@ class ExportOrchestrator:
|
|||
def _run_export(self, export_type: str, params: dict) -> Tuple[bool, str, Optional[str]]:
|
||||
"""Send an export command to the subprocess and wait for result.
|
||||
|
||||
Returns ``(success, message, output_path)``. ``output_path`` is the
|
||||
resolved on-disk directory the worker actually wrote to (None when
|
||||
the export only pushed to Hub or failed before any file was
|
||||
written). Surfaced via the export route's ``details.output_path``
|
||||
so the dialog's success screen can show the user where the model
|
||||
landed.
|
||||
Returns ``(success, message, output_path)``. ``output_path`` is
|
||||
the resolved on-disk dir the worker wrote to (None when the
|
||||
export only pushed to Hub or failed before writing). Surfaced via
|
||||
the export route's ``details.output_path`` so the dialog's success
|
||||
screen shows where the model landed.
|
||||
"""
|
||||
with self._lock:
|
||||
if not self._ensure_subprocess_alive():
|
||||
|
|
@ -527,7 +517,7 @@ class ExportOrchestrator:
|
|||
"""Cleanup export-related models from memory."""
|
||||
with self._lock:
|
||||
if not self._ensure_subprocess_alive():
|
||||
# No subprocess — just clear local state
|
||||
# No subprocess — clear local state.
|
||||
self.current_checkpoint = None
|
||||
self.is_vision = False
|
||||
self.is_peft = False
|
||||
|
|
@ -542,7 +532,7 @@ class ExportOrchestrator:
|
|||
except RuntimeError:
|
||||
success = False
|
||||
|
||||
# Shut down subprocess after cleanup — no model loaded
|
||||
# Shut down subprocess after cleanup — no model loaded.
|
||||
self._shutdown_subprocess()
|
||||
|
||||
self.current_checkpoint = None
|
||||
|
|
@ -553,7 +543,7 @@ class ExportOrchestrator:
|
|||
self._export_active = False
|
||||
|
||||
def scan_checkpoints(self, outputs_dir: str = str(outputs_root())) -> List[Tuple[str, list]]:
|
||||
"""Scan for checkpoints — no ML imports needed, runs locally."""
|
||||
"""Scan for checkpoints — runs locally, no ML imports."""
|
||||
from utils.models.checkpoints import scan_checkpoints
|
||||
return scan_checkpoints(outputs_dir = outputs_dir)
|
||||
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@
|
|||
"""
|
||||
Export subprocess entry point.
|
||||
|
||||
Each export session runs in a persistent subprocess (mp.get_context("spawn")).
|
||||
This gives us a clean Python interpreter with no stale module state —
|
||||
solving the transformers version-switching problem completely.
|
||||
Each export session runs in a persistent subprocess (mp.get_context("spawn")),
|
||||
giving a clean interpreter with no stale module state — solving the
|
||||
transformers version-switching problem completely.
|
||||
|
||||
The subprocess stays alive while a model is loaded, accepting commands
|
||||
(load, export_merged, export_base, export_gguf, export_lora, cleanup,
|
||||
|
|
@ -31,41 +31,39 @@ from typing import Any
|
|||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
# Gate that controls whether captured stdout/stderr lines are forwarded
|
||||
# to the parent's resp_queue (and from there to the export-dialog SSE
|
||||
# stream). Closed by default so the noisy bootstrap phase -- transformers
|
||||
# venv activation, Unsloth/torch imports, base-model resolution, "Top
|
||||
# GGUF/hub models" lists, vision detection, weight loading bars -- is
|
||||
# suppressed in the UI. _handle_export() opens the gate at the start of
|
||||
# the actual export work and leaves it open; the orchestrator always
|
||||
# spawns a fresh subprocess for the next checkpoint load (see
|
||||
# orchestrator._spawn_subprocess) which resets this state.
|
||||
# Gate controlling whether captured stdout/stderr lines are forwarded to
|
||||
# the parent's resp_queue (and on to the export-dialog SSE stream). Closed
|
||||
# by default so the noisy bootstrap phase -- transformers venv activation,
|
||||
# Unsloth/torch imports, base-model resolution, "Top GGUF/hub models" lists,
|
||||
# vision detection, weight loading bars -- is suppressed in the UI.
|
||||
# _handle_export() opens the gate when actual export work starts and leaves
|
||||
# it open; the orchestrator spawns a fresh subprocess for the next checkpoint
|
||||
# load (see orchestrator._spawn_subprocess), resetting this state.
|
||||
#
|
||||
# Lines dropped while the gate is closed are still echoed to the saved
|
||||
# original stdout/stderr fds so the server console / log file keeps the
|
||||
# full output for debugging.
|
||||
# original stdout/stderr fds so the server console / log file keeps the full
|
||||
# output for debugging.
|
||||
_log_forward_gate = threading.Event()
|
||||
|
||||
|
||||
def _setup_log_capture(resp_queue: Any) -> None:
|
||||
"""Redirect fds 1 and 2 through pipes so every line printed by this
|
||||
worker process and any child process it spawns is forwarded to the
|
||||
parent process via resp_queue as {"type": "log", ...} messages.
|
||||
worker and any child it spawns is forwarded to the parent via resp_queue
|
||||
as {"type": "log", ...} messages.
|
||||
|
||||
Must be called BEFORE LogConfig.setup_logging and BEFORE any ML
|
||||
imports, otherwise library handlers may capture the original stderr
|
||||
reference and bypass the pipe.
|
||||
Must run BEFORE LogConfig.setup_logging and any ML imports, else library
|
||||
handlers may capture the original stderr reference and bypass the pipe.
|
||||
|
||||
Lines are also echoed back to the original stdout/stderr so the
|
||||
server console keeps receiving the full subprocess output, even
|
||||
while ``_log_forward_gate`` is closed.
|
||||
Lines are also echoed back to the original stdout/stderr so the server
|
||||
console keeps the full subprocess output, even while
|
||||
``_log_forward_gate`` is closed.
|
||||
"""
|
||||
|
||||
try:
|
||||
saved_out_fd = os.dup(1)
|
||||
saved_err_fd = os.dup(2)
|
||||
except OSError:
|
||||
# dup failed (exotic platforms) - give up quietly, export still
|
||||
# dup failed (exotic platforms) - give up quietly; export still
|
||||
# works, just no live log streaming.
|
||||
return
|
||||
|
||||
|
|
@ -88,13 +86,13 @@ def _setup_log_capture(resp_queue: Any) -> None:
|
|||
pass
|
||||
return
|
||||
|
||||
# Close the write ends we just dup2'd (fds 1 and 2 are the real
|
||||
# write ends now).
|
||||
# Close the write ends we just dup2'd (fds 1 and 2 are now the real
|
||||
# write ends).
|
||||
os.close(w_out)
|
||||
os.close(w_err)
|
||||
|
||||
# Replace Python's sys.stdout/sys.stderr with line-buffered writers
|
||||
# bound to the (now-redirected) fds 1 and 2.
|
||||
# Replace sys.stdout/sys.stderr with line-buffered writers bound to the
|
||||
# (now-redirected) fds 1 and 2.
|
||||
try:
|
||||
sys.stdout = os.fdopen(1, "w", buffering = 1, encoding = "utf-8", errors = "replace")
|
||||
sys.stderr = os.fdopen(2, "w", buffering = 1, encoding = "utf-8", errors = "replace")
|
||||
|
|
@ -112,8 +110,8 @@ def _setup_log_capture(resp_queue: Any) -> None:
|
|||
continue
|
||||
if not chunk:
|
||||
break
|
||||
# Echo to the original fd so the server console still sees
|
||||
# the full output.
|
||||
# Echo to the original fd so the server console keeps the
|
||||
# full output.
|
||||
try:
|
||||
os.write(echo_fd, chunk)
|
||||
except OSError:
|
||||
|
|
@ -133,9 +131,9 @@ def _setup_log_capture(resp_queue: Any) -> None:
|
|||
if not line:
|
||||
continue
|
||||
if not _log_forward_gate.is_set():
|
||||
# Gate closed (bootstrap phase) -- already echoed to
|
||||
# the saved console fd above; drop the line so the
|
||||
# export dialog doesn't see import / vendoring noise.
|
||||
# Gate closed (bootstrap) -- already echoed to the saved
|
||||
# console fd above; drop the line so the export dialog
|
||||
# doesn't see import / vendoring noise.
|
||||
continue
|
||||
try:
|
||||
resp_queue.put_nowait(
|
||||
|
|
@ -147,8 +145,8 @@ def _setup_log_capture(resp_queue: Any) -> None:
|
|||
}
|
||||
)
|
||||
except Exception:
|
||||
# Queue put failed (full, closed, etc.) - drop the
|
||||
# line rather than crash the reader thread.
|
||||
# Queue put failed (full, closed, etc.) - drop the line
|
||||
# rather than crash the reader thread.
|
||||
pass
|
||||
if buf and _log_forward_gate.is_set():
|
||||
try:
|
||||
|
|
@ -181,7 +179,7 @@ def _setup_log_capture(resp_queue: Any) -> None:
|
|||
|
||||
def _activate_transformers_version(model_name: str) -> None:
|
||||
"""Activate the correct transformers version BEFORE any ML imports."""
|
||||
# Ensure backend is on path for utils imports
|
||||
# Ensure backend is on sys.path for utils imports
|
||||
backend_path = str(Path(__file__).resolve().parent.parent.parent)
|
||||
if backend_path not in sys.path:
|
||||
sys.path.insert(0, backend_path)
|
||||
|
|
@ -267,11 +265,11 @@ def _handle_export(backend, cmd: dict, resp_queue: Any) -> None:
|
|||
export_type = cmd["export_type"] # "merged", "base", "gguf", "lora"
|
||||
response_type = f"export_{export_type}_done"
|
||||
|
||||
# Open the log forwarding gate so the user sees the actual export
|
||||
# progress (Unsloth merge bars, file copies, GGUF conversion, etc.)
|
||||
# in the live log panel. The gate stays open for the rest of this
|
||||
# subprocess's life; the orchestrator spawns a fresh subprocess for
|
||||
# the next checkpoint load, which resets the gate to closed.
|
||||
# Open the log forwarding gate so the user sees actual export progress
|
||||
# (Unsloth merge bars, file copies, GGUF conversion, etc.) in the live
|
||||
# log panel. The gate stays open for the rest of this subprocess's life;
|
||||
# the orchestrator spawns a fresh subprocess for the next checkpoint
|
||||
# load, which resets the gate to closed.
|
||||
_log_forward_gate.set()
|
||||
|
||||
output_path: Any = None
|
||||
|
|
@ -372,23 +370,23 @@ def run_export_process(*, cmd_queue: Any, resp_queue: Any, config: dict) -> None
|
|||
"""
|
||||
import queue as _queue
|
||||
|
||||
# Install fd-level stdout/stderr capture FIRST so every subsequent
|
||||
# print and every child process inherits the redirected fds. This
|
||||
# is what powers the live export log stream in the UI.
|
||||
# Install fd-level stdout/stderr capture FIRST so every subsequent print
|
||||
# and every child process inherits the redirected fds. This powers the
|
||||
# live export log stream in the UI.
|
||||
_setup_log_capture(resp_queue)
|
||||
|
||||
os.environ["TOKENIZERS_PARALLELISM"] = "false"
|
||||
os.environ["PYTHONWARNINGS"] = "ignore" # Suppress warnings at C-level before imports
|
||||
# Force unbuffered output from any child Python process (e.g. the
|
||||
# GGUF converter) so their prints surface in the log stream as they
|
||||
# happen rather than at the end.
|
||||
# Force unbuffered output from child Python processes (e.g. the GGUF
|
||||
# converter) so their prints surface in the log stream as they happen,
|
||||
# not at the end.
|
||||
os.environ["PYTHONUNBUFFERED"] = "1"
|
||||
# tqdm defaults to a 10-second mininterval when stdout is not a tty
|
||||
# (which it isn't here -- we redirected fd 1/2 to a pipe). That makes
|
||||
# multi-step progress bars look frozen in the export log panel. Force
|
||||
# frequent flushes so the user sees movement during merge / GGUF
|
||||
# conversion. Has no effect on single-step bars (e.g. "Copying 1
|
||||
# files") which only emit start/end events regardless.
|
||||
# (it isn't -- we redirected fd 1/2 to a pipe), making multi-step
|
||||
# progress bars look frozen in the export log panel. Force frequent
|
||||
# flushes so the user sees movement during merge / GGUF conversion. No
|
||||
# effect on single-step bars (e.g. "Copying 1 files") which only emit
|
||||
# start/end events anyway.
|
||||
os.environ.setdefault("TQDM_MININTERVAL", "0.5")
|
||||
|
||||
import warnings
|
||||
|
|
@ -419,7 +417,7 @@ def run_export_process(*, cmd_queue: Any, resp_queue: Any, config: dict) -> None
|
|||
)
|
||||
return
|
||||
|
||||
# ── 1b. On Windows, check Triton availability (must be before import torch) ──
|
||||
# ── 1b. Check Triton on Windows (must precede import torch) ──
|
||||
if sys.platform == "win32":
|
||||
try:
|
||||
import triton # noqa: F401
|
||||
|
|
@ -433,9 +431,9 @@ def run_export_process(*, cmd_queue: Any, resp_queue: Any, config: dict) -> None
|
|||
|
||||
# ── 1c. Stub torchao on Windows ROCm ──
|
||||
# Shared with the training worker; see core/_torchao_stub.py for the full
|
||||
# rationale (torchao -> torch.distributed._functional_collectives crashes on
|
||||
# Windows ROCm because the RCCL backend is absent). No-op off Windows ROCm.
|
||||
# Must run before any import of transformers / unsloth_zoo.
|
||||
# rationale (torchao -> torch.distributed._functional_collectives crashes
|
||||
# on Windows ROCm: RCCL backend absent). No-op off Windows ROCm. Must run
|
||||
# before importing transformers / unsloth_zoo.
|
||||
from core._torchao_stub import install_torchao_windows_rocm_stub
|
||||
|
||||
install_torchao_windows_rocm_stub()
|
||||
|
|
@ -511,7 +509,7 @@ def run_export_process(*, cmd_queue: Any, resp_queue: Any, config: dict) -> None
|
|||
|
||||
try:
|
||||
if cmd_type == "load":
|
||||
# Load a new checkpoint (reusing this subprocess)
|
||||
# Load a new checkpoint, reusing this subprocess
|
||||
backend.cleanup_memory()
|
||||
_handle_load(backend, cmd, resp_queue)
|
||||
|
||||
|
|
|
|||
|
|
@ -2,17 +2,17 @@
|
|||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""
|
||||
Inference submodule - Inference backend for model loading and generation
|
||||
Inference submodule - backend for model loading and generation.
|
||||
|
||||
The default get_inference_backend() returns an InferenceOrchestrator that
|
||||
delegates to a subprocess. The original InferenceBackend runs inside
|
||||
the subprocess and can be imported directly from .inference when needed.
|
||||
delegates to a subprocess. The original InferenceBackend runs inside the
|
||||
subprocess and can be imported directly from .inference when needed.
|
||||
"""
|
||||
|
||||
from .orchestrator import InferenceOrchestrator, get_inference_backend
|
||||
from .llama_cpp import LlamaCppBackend
|
||||
|
||||
# Expose InferenceOrchestrator as InferenceBackend for backward compat
|
||||
# Expose InferenceOrchestrator as InferenceBackend for backward compat.
|
||||
InferenceBackend = InferenceOrchestrator
|
||||
|
||||
__all__ = [
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
Minimal HTML-to-Markdown converter using only the standard library.
|
||||
|
||||
Replaces the external ``html2text`` (GPL-3.0) dependency with a ~250-line
|
||||
``html.parser.HTMLParser`` subclass. Covers headings, links, bold/italic,
|
||||
``html.parser.HTMLParser`` subclass. Covers headings, links, bold/italic,
|
||||
lists, tables, blockquotes, code blocks, and entity decoding.
|
||||
"""
|
||||
|
||||
|
|
@ -81,9 +81,9 @@ class _MarkdownRenderer(HTMLParser):
|
|||
self._pre_parts: list[str] = []
|
||||
self._in_inline_code: bool = False
|
||||
|
||||
# Blockquote state -- stack of output buffers so nested
|
||||
# blockquotes each collect their own content and get prefixed
|
||||
# with the correct number of ">" markers on close.
|
||||
# Blockquote state -- stack of output buffers so nested blockquotes
|
||||
# each collect their own content and get prefixed with the correct
|
||||
# number of ">" markers on close.
|
||||
self._bq_stack: list[list[str]] = []
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
@ -102,7 +102,7 @@ class _MarkdownRenderer(HTMLParser):
|
|||
# ------------------------------------------------------------------
|
||||
def _prefix_blockquote(self, content: str) -> str:
|
||||
"""Prefix every line of *content* with ``> ``."""
|
||||
# Strip trailing whitespace first, then collapse blank lines
|
||||
# Strip trailing whitespace, then collapse blank lines.
|
||||
content = re.sub(r"[ \t]+$", "", content, flags = re.MULTILINE)
|
||||
content = re.sub(r"\n{3,}", "\n\n", content).strip()
|
||||
if not content:
|
||||
|
|
@ -117,8 +117,8 @@ class _MarkdownRenderer(HTMLParser):
|
|||
return "\n".join(prefixed)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Table helpers -- flush open cells and rows so that HTML with
|
||||
# omitted optional end tags (</td>, </tr>) does not lose data.
|
||||
# Table helpers -- flush open cells/rows so HTML with omitted optional
|
||||
# end tags (</td>, </tr>) does not lose data.
|
||||
# ------------------------------------------------------------------
|
||||
def _finish_cell(self) -> None:
|
||||
if not self._in_cell:
|
||||
|
|
@ -143,8 +143,8 @@ class _MarkdownRenderer(HTMLParser):
|
|||
self._row_has_th = False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Link text helper -- normalize whitespace so block-level content
|
||||
# inside an <a> does not produce multiline Markdown link labels.
|
||||
# Link text helper -- normalize whitespace so block-level content inside
|
||||
# an <a> does not produce multiline Markdown link labels.
|
||||
# ------------------------------------------------------------------
|
||||
def _finish_link(self) -> None:
|
||||
text = re.sub(r"\s+", " ", "".join(self._link_text_parts)).strip()
|
||||
|
|
@ -234,13 +234,13 @@ class _MarkdownRenderer(HTMLParser):
|
|||
self._emit("\n\n")
|
||||
|
||||
elif tag == "tr":
|
||||
# Flush any open cell/row from a previous row that may
|
||||
# have omitted its optional </td> or </tr> end tags.
|
||||
# Flush any open cell/row from a previous row that omitted its
|
||||
# optional </td> or </tr> end tags.
|
||||
self._finish_cell()
|
||||
self._finish_row()
|
||||
|
||||
elif tag in ("th", "td"):
|
||||
# Flush any open cell (handles omitted </td>/<th>)
|
||||
# Flush any open cell (handles omitted </td>/<th>).
|
||||
self._finish_cell()
|
||||
self._cell_parts = []
|
||||
self._in_cell = True
|
||||
|
|
@ -248,8 +248,8 @@ class _MarkdownRenderer(HTMLParser):
|
|||
self._row_has_th = True
|
||||
|
||||
elif tag == "img":
|
||||
# Skip images -- keeps fetched page text focused on readable
|
||||
# content and avoids data-URI amplification.
|
||||
# Skip images -- keeps page text focused on readable content and
|
||||
# avoids data-URI amplification.
|
||||
return
|
||||
|
||||
def handle_endtag(self, tag: str) -> None:
|
||||
|
|
@ -310,7 +310,7 @@ class _MarkdownRenderer(HTMLParser):
|
|||
self._finish_row()
|
||||
|
||||
elif tag == "table":
|
||||
# Flush any remaining row (handles omitted </tr>)
|
||||
# Flush any remaining row (handles omitted </tr>).
|
||||
self._finish_cell()
|
||||
self._finish_row()
|
||||
self._in_table = False
|
||||
|
|
@ -325,15 +325,15 @@ class _MarkdownRenderer(HTMLParser):
|
|||
if self._in_pre:
|
||||
self._pre_parts.append(data)
|
||||
return
|
||||
# Preserve literal whitespace inside inline <code> spans
|
||||
# Preserve literal whitespace inside inline <code> spans.
|
||||
if self._in_inline_code:
|
||||
self._emit(data)
|
||||
return
|
||||
# Collapse all whitespace (including newlines) per HTML rules
|
||||
# Collapse all whitespace (including newlines) per HTML rules.
|
||||
text = re.sub(r"\s+", " ", data)
|
||||
# Suppress whitespace-only text nodes between table structural
|
||||
# elements (indentation from source HTML) to prevent leading
|
||||
# spaces from breaking Markdown table row alignment.
|
||||
# Suppress whitespace-only text nodes between table structural elements
|
||||
# (source indentation) so leading spaces don't break Markdown table
|
||||
# row alignment.
|
||||
if self._in_table and not self._in_cell and not text.strip():
|
||||
return
|
||||
self._emit(text)
|
||||
|
|
@ -354,9 +354,9 @@ class _MarkdownRenderer(HTMLParser):
|
|||
def flush_pending(self) -> None:
|
||||
"""Flush any open side-buffers into ``_out``.
|
||||
|
||||
Called after ``close()`` to recover content from truncated HTML
|
||||
where closing tags were never seen (common when ``_fetch_page_text``
|
||||
caps the download by byte count).
|
||||
Called after ``close()`` to recover content from truncated HTML where
|
||||
closing tags were never seen (common when ``_fetch_page_text`` caps the
|
||||
download by byte count).
|
||||
"""
|
||||
# Flush innermost buffers first so their content propagates outward.
|
||||
|
||||
|
|
@ -376,7 +376,7 @@ class _MarkdownRenderer(HTMLParser):
|
|||
block = "```\n" + raw + "\n```"
|
||||
self._emit("\n\n" + block + "\n\n")
|
||||
|
||||
# Flatten any open blockquote buffers (innermost first)
|
||||
# Flatten any open blockquote buffers (innermost first).
|
||||
while self._bq_stack:
|
||||
content = "".join(self._bq_stack.pop())
|
||||
prefixed = self._prefix_blockquote(content)
|
||||
|
|
@ -394,8 +394,8 @@ class _MarkdownRenderer(HTMLParser):
|
|||
def _cleanup(text: str) -> str:
|
||||
"""Normalize whitespace and blank lines in the final output.
|
||||
|
||||
Preserves content inside fenced code blocks verbatim so that
|
||||
intentional blank lines in ``<pre>`` content are not collapsed.
|
||||
Preserves content inside fenced code blocks verbatim so intentional blank
|
||||
lines in ``<pre>`` content are not collapsed.
|
||||
"""
|
||||
lines = text.split("\n")
|
||||
out: list[str] = []
|
||||
|
|
@ -411,7 +411,7 @@ def _cleanup(text: str) -> str:
|
|||
continue
|
||||
|
||||
if in_fence:
|
||||
# Preserve code block content exactly as-is
|
||||
# Preserve code block content exactly as-is.
|
||||
out.append(line)
|
||||
continue
|
||||
|
||||
|
|
@ -433,11 +433,11 @@ def _cleanup(text: str) -> str:
|
|||
def html_to_markdown(source_html: str) -> str:
|
||||
"""Convert an HTML string to Markdown.
|
||||
|
||||
Handles headings, links, bold/italic, lists (ordered and unordered),
|
||||
tables, blockquotes, code blocks, and HTML entities. ``<script>``,
|
||||
``<style>``, and ``<head>`` sections are stripped entirely.
|
||||
Handles headings, links, bold/italic, ordered/unordered lists, tables,
|
||||
blockquotes, code blocks, and HTML entities. ``<script>``, ``<style>``,
|
||||
and ``<head>`` sections are stripped entirely.
|
||||
"""
|
||||
# Normalize line endings before parsing
|
||||
# Normalize line endings before parsing.
|
||||
source_html = source_html.replace("\r\n", "\n").replace("\r", "\n")
|
||||
renderer = _MarkdownRenderer()
|
||||
renderer.feed(source_html)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"""
|
||||
Anthropic Messages API ↔ OpenAI format translation utilities.
|
||||
|
||||
Pure functions and a stateful stream emitter — no FastAPI, no I/O.
|
||||
Pure functions plus stateful stream emitters; no FastAPI, no I/O.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -46,9 +46,9 @@ def anthropic_messages_to_openai(
|
|||
) -> list[dict]:
|
||||
"""Convert Anthropic messages + system to OpenAI-format message dicts.
|
||||
|
||||
User messages that carry ``image`` blocks are emitted as OpenAI
|
||||
multimodal content arrays (``[{type: "text", ...}, {type: "image_url", ...}]``)
|
||||
so they flow through llama-server's native vision pathway.
|
||||
User messages with ``image`` blocks are emitted as OpenAI multimodal
|
||||
content arrays (``[{type: "text", ...}, {type: "image_url", ...}]``) so
|
||||
they flow through llama-server's native vision pathway.
|
||||
"""
|
||||
result: list[dict] = []
|
||||
|
||||
|
|
@ -104,9 +104,9 @@ def anthropic_messages_to_openai(
|
|||
continue
|
||||
|
||||
if role == "user":
|
||||
# Build an ordered part list so text/image interleaving is
|
||||
# preserved (e.g. [text, image, text, image]). tool_result
|
||||
# blocks become their own OpenAI "tool" role messages.
|
||||
# Ordered part list to preserve text/image interleaving (e.g.
|
||||
# [text, image, text, image]). tool_result blocks become their
|
||||
# own OpenAI "tool" role messages.
|
||||
user_parts: list[dict] = []
|
||||
has_image = False
|
||||
tool_results: list[dict] = []
|
||||
|
|
@ -137,8 +137,8 @@ def anthropic_messages_to_openai(
|
|||
if has_image:
|
||||
result.append({"role": "user", "content": user_parts})
|
||||
else:
|
||||
# No images — collapse text parts to a plain string so
|
||||
# existing text-only callers keep their simple shape.
|
||||
# No images: collapse text parts to a plain string so
|
||||
# text-only callers keep their simple shape.
|
||||
text = "\n".join(p["text"] for p in user_parts)
|
||||
if text:
|
||||
result.append({"role": "user", "content": text})
|
||||
|
|
@ -181,8 +181,8 @@ def anthropic_tool_choice_to_openai(tc: Any) -> Any:
|
|||
- ``{"type": "tool", "name": "get_weather"}``
|
||||
→ ``{"type": "function", "function": {"name": "get_weather"}}``
|
||||
|
||||
Returns ``None`` for ``None`` or any unrecognized shape (caller may
|
||||
then fall back to its own default, typically ``"auto"``).
|
||||
Returns ``None`` for ``None`` or any unrecognized shape (caller falls
|
||||
back to its own default, typically ``"auto"``).
|
||||
"""
|
||||
if tc is None:
|
||||
return None
|
||||
|
|
@ -209,8 +209,8 @@ def build_anthropic_sse_event(event_type: str, data: dict) -> str:
|
|||
|
||||
|
||||
class AnthropicStreamEmitter:
|
||||
"""Converts generator events from generate_chat_completion_with_tools()
|
||||
into Anthropic Messages SSE strings."""
|
||||
"""Converts generate_chat_completion_with_tools() events into Anthropic
|
||||
Messages SSE strings."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.block_index: int = 0
|
||||
|
|
@ -320,8 +320,8 @@ class AnthropicStreamEmitter:
|
|||
# Close current text block if open.
|
||||
if self._text_block_open:
|
||||
events.append(self._close_block())
|
||||
# Defensive: if a replacement/different tool_start arrives while a
|
||||
# tool_use block is open, close the stale block before starting another.
|
||||
# Defensive: if a different tool_start arrives while a tool_use
|
||||
# block is open, close the stale block before starting another.
|
||||
elif self._open_tool_call_id is not None:
|
||||
events.append(self._close_block())
|
||||
self._open_tool_call_id = None
|
||||
|
|
@ -421,10 +421,10 @@ class AnthropicStreamEmitter:
|
|||
class AnthropicPassthroughEmitter:
|
||||
"""Converts llama-server's OpenAI-format streaming chunks into Anthropic SSE.
|
||||
|
||||
Used for the client-side tool-use pass-through path: the client (e.g. Claude
|
||||
Code) sends its own tool definitions in the ``tools`` field and expects to
|
||||
execute them itself. We forward them to llama-server and translate the
|
||||
streaming response back to Anthropic format without executing anything.
|
||||
Used for the client-side tool-use pass-through path: the client (e.g.
|
||||
Claude Code) sends its own tool definitions in ``tools`` and executes
|
||||
them itself. We forward them to llama-server and translate the streaming
|
||||
response back to Anthropic format without executing anything.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
|
|
|
|||
|
|
@ -90,8 +90,8 @@ class AudioCodecManager:
|
|||
import os
|
||||
import sys
|
||||
|
||||
# Clone SparkAudio/Spark-TTS GitHub repo for the sparktts Python package
|
||||
# (same approach as training — the HF model repos don't contain the package)
|
||||
# Clone SparkAudio/Spark-TTS for the sparktts package (same as
|
||||
# training; the HF model repos don't contain it)
|
||||
spark_code_dir = os.path.join(os.path.dirname(model_repo_path or "."), "Spark-TTS")
|
||||
sparktts_pkg = os.path.join(spark_code_dir, "sparktts")
|
||||
if not os.path.isdir(sparktts_pkg):
|
||||
|
|
@ -115,7 +115,7 @@ class AudioCodecManager:
|
|||
|
||||
from sparktts.models.audio_tokenizer import BiCodecTokenizer
|
||||
|
||||
# BiCodecTokenizer needs the MODEL repo path (contains BiCodec/ weights)
|
||||
# BiCodecTokenizer needs the MODEL repo path (has BiCodec/ weights)
|
||||
tokenizer_path = model_repo_path or spark_code_dir
|
||||
self._bicodec_repo_path = tokenizer_path
|
||||
self._bicodec_tokenizer = BiCodecTokenizer(tokenizer_path, device)
|
||||
|
|
@ -127,9 +127,9 @@ class AudioCodecManager:
|
|||
import os
|
||||
import sys
|
||||
|
||||
# Clone OuteTTS repo (same pattern as Spark-TTS / BiCodec)
|
||||
# The pip package has problematic dependencies; the notebook clones and
|
||||
# removes gguf_model.py, interface.py, __init__.py before importing.
|
||||
# Clone OuteTTS (same pattern as Spark-TTS / BiCodec). The pip
|
||||
# package has problematic deps; the notebook clones and removes
|
||||
# gguf_model.py, interface.py, __init__.py before importing.
|
||||
base_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
outetts_code_dir = os.path.join(base_dir, "OuteTTS")
|
||||
outetts_pkg = os.path.join(outetts_code_dir, "outetts")
|
||||
|
|
@ -148,8 +148,8 @@ class AudioCodecManager:
|
|||
env = child_env_without_native_path_secret(),
|
||||
**_windows_hidden_subprocess_kwargs(),
|
||||
)
|
||||
# Remove files that pull in heavy / incompatible dependencies
|
||||
# (matches notebook: gguf_model.py is under models/, others under outetts/)
|
||||
# Remove files pulling in heavy / incompatible deps (matches
|
||||
# notebook: gguf_model.py under models/, others under outetts/)
|
||||
remove_paths = [
|
||||
os.path.join(outetts_pkg, "models", "gguf_model.py"),
|
||||
os.path.join(outetts_pkg, "interface.py"),
|
||||
|
|
@ -181,9 +181,9 @@ class AudioCodecManager:
|
|||
"""
|
||||
Decode SNAC tokens (Orpheus) into WAV bytes.
|
||||
|
||||
generated_ids: full model output including prompt tokens.
|
||||
Looks for START_OF_SPEECH (128257) marker, extracts codes after it,
|
||||
strips EOS (128258), redistributes 7-per-frame codes into 3 SNAC layers.
|
||||
generated_ids: full model output including prompt tokens. Finds the
|
||||
START_OF_SPEECH (128257) marker, extracts codes after it, strips EOS
|
||||
(128258), redistributes 7-per-frame codes into 3 SNAC layers.
|
||||
|
||||
Returns (wav_bytes, 24000).
|
||||
"""
|
||||
|
|
@ -192,7 +192,7 @@ class AudioCodecManager:
|
|||
if len(token_indices[1]) > 0:
|
||||
cropped = generated_ids[:, token_indices[1][-1] + 1 :]
|
||||
else:
|
||||
# Gracefully fall back to using entire output if marker not found
|
||||
# Fall back to the entire output if the marker is missing
|
||||
logger.warning("No START_OF_SPEECH token (128257) found — using full generated output")
|
||||
cropped = generated_ids
|
||||
row = cropped[0]
|
||||
|
|
@ -256,8 +256,8 @@ class AudioCodecManager:
|
|||
|
||||
semantic_ids = torch.tensor([int(t) for t in semantic_matches]).long().unsqueeze(0)
|
||||
|
||||
# Speaker encoder expects exactly 32 global tokens (token_num=32 in BiCodec config).
|
||||
# Pad with zeros or truncate to 32.
|
||||
# Speaker encoder expects exactly 32 global tokens (token_num=32 in
|
||||
# BiCodec config). Pad with zeros or truncate to 32.
|
||||
GLOBAL_TOKEN_NUM = 32
|
||||
if global_matches:
|
||||
raw = [int(t) for t in global_matches]
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""
|
||||
Dependency-light wrapper around tokenizer.apply_chat_template with a
|
||||
kwarg fallback for templates that reject reasoning/tools args.
|
||||
Dependency-light wrapper around tokenizer.apply_chat_template with a kwarg
|
||||
fallback for templates that reject reasoning/tools args.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,9 +1,7 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""
|
||||
Core inference backend - streamlined
|
||||
"""
|
||||
"""Core inference backend."""
|
||||
|
||||
from unsloth import FastLanguageModel, FastVisionModel
|
||||
from unsloth.chat_templates import get_chat_template
|
||||
|
|
@ -36,29 +34,25 @@ logger = get_logger(__name__)
|
|||
|
||||
|
||||
class HarmonyTextStreamer:
|
||||
"""Streaming text decoder for gpt-oss harmony channel protocol.
|
||||
"""Streaming text decoder for the gpt-oss harmony channel protocol.
|
||||
|
||||
gpt-oss models emit multi-channel output using special tokens like
|
||||
``<|channel|>analysis<|message|>...`` and ``<|channel|>final<|message|>...``.
|
||||
A plain ``TextIteratorStreamer(skip_special_tokens=True)`` strips the special
|
||||
tokens but leaves the channel names concatenated with content, producing
|
||||
garbled output such as ``analysisWe need to respond...assistantfinalHello!``.
|
||||
gpt-oss emits multi-channel output via special tokens like
|
||||
``<|channel|>analysis<|message|>...`` / ``<|channel|>final<|message|>...``.
|
||||
A plain ``TextIteratorStreamer(skip_special_tokens=True)`` drops the tokens
|
||||
but leaves channel names glued to content, producing garbled output like
|
||||
``analysisWe need to respond...assistantfinalHello!``.
|
||||
|
||||
This streamer decodes with ``skip_special_tokens=False`` so the full
|
||||
harmony markup is visible, then uses **stateful incremental** parsing
|
||||
to emit properly-formatted text:
|
||||
This streamer decodes with ``skip_special_tokens=False`` so the harmony
|
||||
markup is visible, then uses stateful incremental parsing:
|
||||
|
||||
- ``<think>`` emitted once when the ``analysis`` channel is first seen
|
||||
- Analysis content streamed incrementally
|
||||
- ``</think>`` emitted once when the ``final`` channel is first seen
|
||||
- Final content streamed incrementally
|
||||
- ``<think>`` once when ``analysis`` channel is first seen
|
||||
- analysis content streamed incrementally
|
||||
- ``</think>`` once when ``final`` channel is first seen
|
||||
- final content streamed incrementally
|
||||
|
||||
This avoids the delta-on-transformed bug where wrapping tags shift
|
||||
position as content grows.
|
||||
|
||||
Implements the same ``put`` / ``end`` / iterator interface as
|
||||
``TextIteratorStreamer`` so ``generate_stream`` can use it as a drop-in
|
||||
replacement.
|
||||
position as content grows. Implements the same ``put`` / ``end`` / iterator
|
||||
interface as ``TextIteratorStreamer`` for drop-in use in ``generate_stream``.
|
||||
"""
|
||||
|
||||
import re as _re
|
||||
|
|
@ -87,7 +81,7 @@ class HarmonyTextStreamer:
|
|||
self._is_first_put: bool = True
|
||||
self._stop: bool = False
|
||||
|
||||
# Stateful channel tracking — avoids delta-on-transformed bugs
|
||||
# Stateful channel tracking avoids delta-on-transformed bugs
|
||||
self._emitted_think_open: bool = False
|
||||
self._emitted_think_close: bool = False
|
||||
self._analysis_emitted: int = 0 # chars of analysis content emitted
|
||||
|
|
@ -102,7 +96,7 @@ class HarmonyTextStreamer:
|
|||
import torch
|
||||
|
||||
if isinstance(value, torch.Tensor):
|
||||
# value shape: (batch, seq) — take first batch element
|
||||
# shape (batch, seq) — take first batch element
|
||||
ids = value[0].tolist() if value.dim() > 1 else value.tolist()
|
||||
elif isinstance(value, (list, tuple)):
|
||||
ids = list(value)
|
||||
|
|
@ -110,7 +104,7 @@ class HarmonyTextStreamer:
|
|||
ids = [value]
|
||||
|
||||
if self._is_first_put and self.skip_prompt:
|
||||
# First call contains the full prompt; remember its length
|
||||
# First call is the full prompt; remember its length
|
||||
self._prompt_len = len(ids)
|
||||
self._token_ids = list(ids)
|
||||
self._is_first_put = False
|
||||
|
|
@ -125,7 +119,7 @@ class HarmonyTextStreamer:
|
|||
|
||||
def end(self):
|
||||
"""Signal generation is complete."""
|
||||
# Final decode to capture any remaining content
|
||||
# Final decode to capture remaining content
|
||||
gen_ids = self._token_ids[self._prompt_len :]
|
||||
if gen_ids:
|
||||
raw = self.tokenizer.decode(gen_ids, skip_special_tokens = False)
|
||||
|
|
@ -164,19 +158,19 @@ class HarmonyTextStreamer:
|
|||
# ------------------------------------------------------------------
|
||||
|
||||
def _process_incremental(self, raw: str) -> None:
|
||||
"""Parse harmony channels and emit deltas per-channel.
|
||||
"""Parse harmony channels and emit per-channel deltas.
|
||||
|
||||
Instead of transforming the entire raw text and computing a string
|
||||
delta (which breaks when wrapping ``<think>`` tags shift position),
|
||||
this tracks per-channel content lengths and emits:
|
||||
Tracks per-channel content lengths (instead of diffing transformed
|
||||
whole-text, which breaks when wrapping ``<think>`` tags shift position)
|
||||
and emits:
|
||||
|
||||
- ``<think>`` once when analysis channel first appears
|
||||
- analysis content deltas (computed on channel content directly)
|
||||
- ``</think>`` once when final channel first appears
|
||||
- final content deltas
|
||||
"""
|
||||
# If raw contains <|channel|> but no complete channel+message pair yet,
|
||||
# buffer silently — don't emit partial channel names as text.
|
||||
# raw has <|channel|> but no complete channel+message pair yet:
|
||||
# buffer silently, don't emit partial channel names as text.
|
||||
has_channel_token = "<|channel|>" in raw
|
||||
matches = list(self._HARMONY_RE.finditer(raw))
|
||||
|
||||
|
|
@ -185,8 +179,7 @@ class HarmonyTextStreamer:
|
|||
return
|
||||
|
||||
if not has_channel_token and not matches:
|
||||
# No harmony protocol at all — should not happen for gpt-oss
|
||||
# but handle gracefully by not emitting anything
|
||||
# No harmony protocol (shouldn't happen for gpt-oss) — emit nothing
|
||||
return
|
||||
|
||||
for m in matches:
|
||||
|
|
@ -228,11 +221,11 @@ class InferenceBackend:
|
|||
self.device = get_device().value
|
||||
self._audio_codec_manager = AudioCodecManager()
|
||||
|
||||
# Thread safety — _generation_lock serializes model.generate() calls.
|
||||
# Must be a regular Lock (NOT RLock) because in async FastAPI, multiple
|
||||
# requests share the same event-loop thread, so RLock reentrancy lets
|
||||
# concurrent compare-mode requests race on the GPU. The lock is
|
||||
# acquired by the *background generation thread*, not the event-loop.
|
||||
# _generation_lock serializes model.generate() calls. Must be a plain
|
||||
# Lock (NOT RLock): in async FastAPI requests share one event-loop
|
||||
# thread, so RLock reentrancy would let concurrent compare-mode
|
||||
# requests race on the GPU. Acquired by the *background generation
|
||||
# thread*, not the event-loop.
|
||||
import threading
|
||||
|
||||
self._generation_lock = threading.Lock()
|
||||
|
|
@ -242,7 +235,7 @@ class InferenceBackend:
|
|||
|
||||
@staticmethod
|
||||
def _normalize_top_k(top_k: int) -> int:
|
||||
# API supports -1 as "disable top-k"; transformers expects 0 to disable.
|
||||
# API uses -1 to disable top-k; transformers uses 0.
|
||||
return 0 if top_k < 0 else top_k
|
||||
|
||||
def load_model(
|
||||
|
|
@ -255,9 +248,7 @@ class InferenceBackend:
|
|||
trust_remote_code: bool = False,
|
||||
gpu_ids: Optional[list[int]] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Load any model: base, LoRA adapter, text, or vision.
|
||||
"""
|
||||
"""Load any model: base, LoRA adapter, text, or vision."""
|
||||
# GGUF uses max_seq_length=0 as "model default"; Unsloth crashes on it.
|
||||
if max_seq_length <= 0:
|
||||
max_seq_length = 2048
|
||||
|
|
@ -265,13 +256,13 @@ class InferenceBackend:
|
|||
try:
|
||||
model_name = config.identifier
|
||||
|
||||
# Check if already loaded
|
||||
# Already loaded?
|
||||
if model_name in self.models and self.models[model_name].get("model"):
|
||||
logger.info(f"Model {model_name} already loaded")
|
||||
self.active_model_name = model_name
|
||||
return True
|
||||
|
||||
# Check if currently loading
|
||||
# Currently loading?
|
||||
if model_name in self.loading_models:
|
||||
logger.info(f"Model {model_name} is already being loaded")
|
||||
return False
|
||||
|
|
@ -322,9 +313,9 @@ class InferenceBackend:
|
|||
from unsloth import FastModel
|
||||
|
||||
if config.is_lora and config.base_model:
|
||||
# LoRA adapter: load from local adapter path.
|
||||
# base_model is e.g. /home/.../Spark-TTS-0.5B/LLM
|
||||
# The BiCodec weights are in the parent dir (Spark-TTS-0.5B/).
|
||||
# LoRA adapter from local path. base_model is e.g.
|
||||
# /home/.../Spark-TTS-0.5B/LLM; BiCodec weights live in
|
||||
# the parent dir (Spark-TTS-0.5B/).
|
||||
base_path = config.base_model
|
||||
if os.path.isdir(base_path):
|
||||
abs_repo_path = os.path.abspath(os.path.dirname(base_path))
|
||||
|
|
@ -348,7 +339,7 @@ class InferenceBackend:
|
|||
trust_remote_code = trust_remote_code,
|
||||
)
|
||||
else:
|
||||
# Base model: download full HF repo, then load from /LLM subfolder
|
||||
# Base model: download full HF repo, load from /LLM subfolder
|
||||
from huggingface_hub import snapshot_download
|
||||
|
||||
hf_repo = config.path
|
||||
|
|
@ -406,7 +397,7 @@ class InferenceBackend:
|
|||
FastModel.for_inference(model)
|
||||
model.eval()
|
||||
|
||||
# Create ASR pipeline (per notebook)
|
||||
# ASR pipeline (per notebook)
|
||||
from transformers import pipeline as hf_pipeline
|
||||
|
||||
whisper_pipe = hf_pipeline(
|
||||
|
|
@ -435,8 +426,8 @@ class InferenceBackend:
|
|||
self.models[model_name]["model"] = model
|
||||
self.models[model_name]["tokenizer"] = tokenizer
|
||||
|
||||
# Load the external codec for TTS audio types
|
||||
# (Whisper is ASR, audio_vlm is audio input — neither needs a codec)
|
||||
# Load external codec for TTS audio types
|
||||
# (Whisper is ASR, audio_vlm is audio input — neither needs one)
|
||||
if audio_type not in ("whisper", "audio_vlm"):
|
||||
model_repo_path = self.models[model_name].get("model_repo_path")
|
||||
self._audio_codec_manager.load_codec(
|
||||
|
|
@ -457,7 +448,7 @@ class InferenceBackend:
|
|||
logger.info(f"Loading {model_type} model{adapter_info}: {model_name}")
|
||||
log_gpu_memory(f"Before loading {model_name}")
|
||||
|
||||
# Load model - same approach for base models and LoRA adapters
|
||||
# Same load path for base models and LoRA adapters
|
||||
if config.is_vision:
|
||||
# Vision model (or vision LoRA adapter)
|
||||
model, processor = FastVisionModel.from_pretrained(
|
||||
|
|
@ -470,19 +461,19 @@ class InferenceBackend:
|
|||
trust_remote_code = trust_remote_code,
|
||||
)
|
||||
|
||||
# Apply inference optimization
|
||||
FastVisionModel.for_inference(model)
|
||||
|
||||
# FastVisionModel may return a raw tokenizer (e.g. GemmaTokenizerFast)
|
||||
# instead of a proper Processor for some models (e.g. Gemma-3).
|
||||
# In that case, load the real processor from the base model.
|
||||
# FastVisionModel may return a raw tokenizer (e.g.
|
||||
# GemmaTokenizerFast) instead of a proper Processor for some
|
||||
# models (e.g. Gemma-3). If so, load the real processor from
|
||||
# the base model.
|
||||
from transformers import ProcessorMixin
|
||||
|
||||
if not (
|
||||
isinstance(processor, ProcessorMixin) or hasattr(processor, "image_processor")
|
||||
):
|
||||
# For LoRA adapters, use the base model. For local merged exports,
|
||||
# read export_metadata.json to find the original base model.
|
||||
# LoRA adapters: use the base model. Local merged exports:
|
||||
# read export_metadata.json for the original base model.
|
||||
processor_source = config.base_model if config.is_lora else config.identifier
|
||||
if not config.is_lora and config.is_local:
|
||||
_meta_path = Path(config.path) / "export_metadata.json"
|
||||
|
|
@ -522,7 +513,6 @@ class InferenceBackend:
|
|||
trust_remote_code = trust_remote_code,
|
||||
)
|
||||
|
||||
# Apply inference optimization
|
||||
FastLanguageModel.for_inference(model)
|
||||
|
||||
self.models[model_name]["model"] = model
|
||||
|
|
@ -530,7 +520,6 @@ class InferenceBackend:
|
|||
|
||||
raise_if_offloaded(self.models[model_name]["model"], device_map, "Inference")
|
||||
|
||||
# Load chat template info
|
||||
self._load_chat_template_info(model_name)
|
||||
|
||||
self.active_model_name = model_name
|
||||
|
|
@ -552,29 +541,25 @@ class InferenceBackend:
|
|||
raise Exception(error_msg)
|
||||
|
||||
def unload_model(self, model_name: str) -> bool:
|
||||
"""
|
||||
Completely removes a model from the registry and clears GPU memory.
|
||||
"""
|
||||
"""Remove a model from the registry and clear GPU memory."""
|
||||
if model_name in self.models:
|
||||
try:
|
||||
# If this was an audio model, clean up codecs
|
||||
# Clean up codecs for audio models
|
||||
if self.models[model_name].get("is_audio"):
|
||||
self._audio_codec_manager.unload()
|
||||
|
||||
logger.info(f"Unloading model '{model_name}' from memory.")
|
||||
# Delete the model entry from our registry
|
||||
del self.models[model_name]
|
||||
|
||||
# Clear the active model if it was the one being unloaded
|
||||
# Clear the active model if it was the one unloaded
|
||||
if self.active_model_name == model_name:
|
||||
self.active_model_name = None
|
||||
|
||||
# Clear GPU memory cache
|
||||
clear_gpu_cache()
|
||||
|
||||
# Remove stale compiled cache so the next model gets a fresh one.
|
||||
# On spawn-based platforms, preserve trainer files so that any
|
||||
# concurrent training dataset.map() workers can still import them.
|
||||
# Drop stale compiled cache so the next model gets a fresh one.
|
||||
# On spawn-based platforms, preserve trainer files so concurrent
|
||||
# training dataset.map() workers can still import them.
|
||||
import sys as _sys
|
||||
from utils.cache_cleanup import clear_unsloth_compiled_cache
|
||||
|
||||
|
|
@ -593,26 +578,23 @@ class InferenceBackend:
|
|||
return True
|
||||
|
||||
def revert_to_base_model(self, base_model_name: str) -> bool:
|
||||
"""
|
||||
Reverts the model to its pristine base state by unloading AND
|
||||
deleting all adapter configurations, as instructed.
|
||||
"""
|
||||
"""Revert the model to its pristine base state by unloading and
|
||||
deleting all adapter configurations."""
|
||||
if base_model_name not in self.models:
|
||||
return False
|
||||
|
||||
model = self.models[base_model_name].get("model")
|
||||
|
||||
try:
|
||||
# Step 1: Unload the adapter weights if model is a PeftModel.
|
||||
# Unload adapter weights if model is a PeftModel.
|
||||
if isinstance(model, (PeftModel, PeftModelForCausalLM)):
|
||||
logger.info(f"Unloading LoRA adapters from '{base_model_name}'...")
|
||||
unwrapped_base_model = model.unload()
|
||||
self.models[base_model_name]["model"] = unwrapped_base_model
|
||||
model = unwrapped_base_model
|
||||
|
||||
# Step 2: Clear any lingering peft_config from the unwrapped model.
|
||||
# After model.unload(), the base model may still carry a peft_config
|
||||
# attribute. Removing it ensures PeftModel.from_pretrained() gets
|
||||
# Clear any lingering peft_config. model.unload() can leave a
|
||||
# peft_config attribute; removing it gives PeftModel.from_pretrained()
|
||||
# a clean base model without "multiple adapters" warnings.
|
||||
if hasattr(model, "peft_config"):
|
||||
del model.peft_config
|
||||
|
|
@ -636,10 +618,8 @@ class InferenceBackend:
|
|||
hf_token: Optional[str] = None,
|
||||
gpu_ids: Optional[list[int]] = None,
|
||||
) -> Tuple[bool, Optional[str], Optional[str]]:
|
||||
"""
|
||||
Final Corrected Version:
|
||||
Ensures the base model and the specified adapter are loaded.
|
||||
This function is idempotent and handles all states correctly.
|
||||
"""Ensure the base model and the given adapter are loaded.
|
||||
Idempotent and handles all states correctly.
|
||||
"""
|
||||
try:
|
||||
from utils.models import ModelConfig
|
||||
|
|
@ -650,7 +630,7 @@ class InferenceBackend:
|
|||
|
||||
base_model_name = lora_config.base_model
|
||||
|
||||
# 1. Load the base model if it's not already in memory
|
||||
# 1. Load the base model if not already in memory
|
||||
if base_model_name not in self.models or not self.models[base_model_name].get("model"):
|
||||
logger.info(f"Base model '{base_model_name}' not loaded, loading now.")
|
||||
base_config = ModelConfig.from_ui_selection(base_model_name, None, is_lora = False)
|
||||
|
|
@ -666,11 +646,11 @@ class InferenceBackend:
|
|||
|
||||
self.active_model_name = base_model_name
|
||||
|
||||
# 2. Determine the required adapter name from the user's selection
|
||||
# 2. Derive adapter name from the user's selection
|
||||
adapter_name = lora_path.split("/")[-1].replace(".", "_")
|
||||
|
||||
# 3. Call our robust load_adapter function to ensure this specific adapter is loaded.
|
||||
# It will only load from disk if the model doesn't already have it.
|
||||
# 3. Ensure this adapter is loaded (load_adapter only reads from
|
||||
# disk if the model doesn't already have it).
|
||||
adapter_success = self.load_adapter(
|
||||
base_model_name = base_model_name,
|
||||
adapter_path = lora_path,
|
||||
|
|
@ -679,7 +659,7 @@ class InferenceBackend:
|
|||
if not adapter_success:
|
||||
return False, base_model_name, None
|
||||
|
||||
# 4. Return the correct, verified adapter name for the UI logic to use.
|
||||
# 4. Return the verified adapter name for the UI.
|
||||
return True, base_model_name, adapter_name
|
||||
|
||||
except Exception as e:
|
||||
|
|
@ -690,12 +670,10 @@ class InferenceBackend:
|
|||
return False, None, None
|
||||
|
||||
def load_adapter(self, base_model_name: str, adapter_path: str, adapter_name: str) -> bool:
|
||||
"""
|
||||
Loads an adapter onto the model ONLY if it's not already attached.
|
||||
"""
|
||||
"""Load an adapter onto the model only if not already attached."""
|
||||
model = self.models[base_model_name].get("model")
|
||||
|
||||
# Check if this adapter name is already part of the model's config. This is the most reliable check.
|
||||
# Most reliable check: adapter name already in the model's config.
|
||||
if hasattr(model, "peft_config") and adapter_name in model.peft_config:
|
||||
logger.info(
|
||||
f"Adapter '{adapter_name}' is already attached to the model. Skipping load."
|
||||
|
|
@ -708,7 +686,7 @@ class InferenceBackend:
|
|||
)
|
||||
model.load_adapter(adapter_path, adapter_name = adapter_name)
|
||||
|
||||
# Update our internal registry ONLY after a successful load.
|
||||
# Update the registry only after a successful load.
|
||||
if "loaded_adapters" not in self.models[base_model_name]:
|
||||
self.models[base_model_name]["loaded_adapters"] = {}
|
||||
self.models[base_model_name]["loaded_adapters"][adapter_name] = adapter_path
|
||||
|
|
@ -723,9 +701,8 @@ class InferenceBackend:
|
|||
return False
|
||||
|
||||
def set_active_adapter(self, base_model_name: str, adapter_name: str) -> bool:
|
||||
"""
|
||||
Sets the active adapter for generation. This replaces the flawed 'enable_adapter'.
|
||||
"""
|
||||
"""Set the active adapter for generation (replaces the flawed
|
||||
'enable_adapter')."""
|
||||
model = self.models[base_model_name].get("model")
|
||||
try:
|
||||
logger.info(f"Setting active adapter to: '{adapter_name}'")
|
||||
|
|
@ -733,18 +710,17 @@ class InferenceBackend:
|
|||
self.models[base_model_name]["active_adapter"] = adapter_name
|
||||
return True
|
||||
except Exception as e:
|
||||
# This will catch the "adapter not found" error if something goes wrong.
|
||||
# Catches "adapter not found" if something goes wrong.
|
||||
logger.error(f"Failed to set active adapter to '{adapter_name}': {e}")
|
||||
return False
|
||||
|
||||
def _apply_adapter_state(self, use_adapter: Optional[Union[bool, str]]) -> None:
|
||||
"""
|
||||
Apply adapter state before generation. Must be called under _generation_lock.
|
||||
"""Apply adapter state before generation. Must hold _generation_lock.
|
||||
|
||||
Uses PEFT's disable_adapter_layers() / enable_adapter_layers() which toggle
|
||||
a boolean flag on each LoRA layer. Unsloth's fast_linear_forward checks this
|
||||
flag (proj.disable_adapters) and skips LoRA computation when True.
|
||||
This is non-destructive — no model unloading/reloading needed.
|
||||
Uses PEFT's disable_adapter_layers() / enable_adapter_layers(), which
|
||||
toggle a flag (proj.disable_adapters) on each LoRA layer; Unsloth's
|
||||
fast_linear_forward checks it and skips LoRA when True. Non-destructive,
|
||||
no unload/reload.
|
||||
|
||||
Args:
|
||||
use_adapter: None = no change, False = disable (base model),
|
||||
|
|
@ -781,7 +757,7 @@ class InferenceBackend:
|
|||
logger.warning("use_adapter=true but model is not a PeftModel")
|
||||
|
||||
elif isinstance(use_adapter, str):
|
||||
# Enable adapters and set the specific one active
|
||||
# Enable adapters and set the named one active
|
||||
if isinstance(model, (PeftModel, PeftModelForCausalLM)):
|
||||
logger.info(f"Compare mode: enabling adapter '{use_adapter}' on '{base}'")
|
||||
model.base_model.enable_adapter_layers()
|
||||
|
|
@ -795,13 +771,12 @@ class InferenceBackend:
|
|||
cancel_event = None,
|
||||
**gen_kwargs,
|
||||
) -> Generator[str, None, None]:
|
||||
"""
|
||||
Thread-safe generation with optional adapter toggling.
|
||||
"""Thread-safe generation with optional adapter toggling.
|
||||
|
||||
The adapter toggle + model.generate() are serialized by _generation_lock
|
||||
inside the background generation thread — NOT in the event-loop thread.
|
||||
This prevents the RLock-reentrant race that occurs when two async SSE
|
||||
handlers share the same event-loop thread.
|
||||
in the background generation thread, NOT the event-loop thread. Prevents
|
||||
the RLock-reentrant race when two async SSE handlers share one
|
||||
event-loop thread.
|
||||
|
||||
Args:
|
||||
use_adapter: Adapter control (None/False/True/str). See _apply_adapter_state.
|
||||
|
|
@ -833,9 +808,8 @@ class InferenceBackend:
|
|||
):
|
||||
"""Run an agentic tool loop on top of ``generate_chat_response``.
|
||||
|
||||
Yields the same event-dict protocol used by the GGUF path so
|
||||
the route layer can stream both backends through one helper.
|
||||
Each event is one of:
|
||||
Yields the same event-dict protocol as the GGUF path so the route
|
||||
layer can stream both backends through one helper. Each event is one of:
|
||||
|
||||
* ``{"type": "status", "text": ...}``
|
||||
* ``{"type": "content", "text": cumulative_text}``
|
||||
|
|
@ -896,15 +870,14 @@ class InferenceBackend:
|
|||
reasoning_effort: Optional[str] = None,
|
||||
preserve_thinking: Optional[bool] = None,
|
||||
) -> Generator[str, None, None]:
|
||||
"""
|
||||
Generate response for text or vision models.
|
||||
"""Generate response for text or vision models.
|
||||
The generation lock is acquired by the background generation thread.
|
||||
|
||||
``tools`` / ``enable_thinking`` / ``reasoning_effort`` /
|
||||
``preserve_thinking`` are forwarded into
|
||||
``tokenizer.apply_chat_template`` so templates that understand
|
||||
these kwargs (Qwen3, Llama 3.1+, gpt-oss harmony, ...) advertise
|
||||
the tool schemas and reasoning controls to the model.
|
||||
``tokenizer.apply_chat_template`` so templates that understand them
|
||||
(Qwen3, Llama 3.1+, gpt-oss harmony, ...) advertise the tool schemas
|
||||
and reasoning controls to the model.
|
||||
"""
|
||||
yield from self._generate_chat_response_inner(
|
||||
messages = messages,
|
||||
|
|
@ -941,9 +914,8 @@ class InferenceBackend:
|
|||
reasoning_effort: Optional[str] = None,
|
||||
preserve_thinking: Optional[bool] = None,
|
||||
) -> Generator[str, None, None]:
|
||||
"""
|
||||
Inner generation logic. Called by both generate_chat_response
|
||||
and generate_with_adapter_control.
|
||||
"""Inner generation logic, called by generate_chat_response and
|
||||
generate_with_adapter_control.
|
||||
|
||||
_adapter_state is passed to generate_stream/vision so the background
|
||||
thread can toggle adapters under the generation lock.
|
||||
|
|
@ -960,10 +932,10 @@ class InferenceBackend:
|
|||
top_k = self._normalize_top_k(top_k)
|
||||
|
||||
if is_vision and image:
|
||||
# Vision model generation (only when an image is actually provided)
|
||||
# Check that the stored processor can actually handle images.
|
||||
# FastVisionModel may return a raw tokenizer (e.g. GemmaTokenizerFast)
|
||||
# instead of a proper ProcessorMixin for some models (e.g. Gemma-3).
|
||||
# Vision generation (only with an actual image). Verify the stored
|
||||
# processor can handle images — FastVisionModel may return a raw
|
||||
# tokenizer (e.g. GemmaTokenizerFast) instead of a ProcessorMixin
|
||||
# for some models (e.g. Gemma-3).
|
||||
from transformers import ProcessorMixin
|
||||
|
||||
processor = model_info.get("processor")
|
||||
|
|
@ -991,10 +963,10 @@ class InferenceBackend:
|
|||
f"falling back to text-only generation (image will be ignored)."
|
||||
)
|
||||
|
||||
# Text path: Use training pipeline approach
|
||||
# Messages are already in ChatML format from eval.py
|
||||
# Text path: training-pipeline approach. Messages are already in
|
||||
# ChatML format from eval.py.
|
||||
|
||||
# Step 1: Apply get_chat_template if model is in mapper
|
||||
# Step 1: apply get_chat_template if model is in mapper
|
||||
try:
|
||||
from utils.datasets import (
|
||||
MODEL_TO_TEMPLATE_MAPPER,
|
||||
|
|
@ -1002,14 +974,13 @@ class InferenceBackend:
|
|||
)
|
||||
model_name_lower = self.active_model_name.lower()
|
||||
|
||||
# Check if model has a registered template
|
||||
if model_name_lower in MODEL_TO_TEMPLATE_MAPPER:
|
||||
template_name = MODEL_TO_TEMPLATE_MAPPER[model_name_lower]
|
||||
logger.info(
|
||||
f"Applying chat template '{template_name}' for {self.active_model_name}"
|
||||
)
|
||||
|
||||
# This modifies the tokenizer with the correct template
|
||||
# Sets the correct template on the tokenizer
|
||||
tokenizer = get_chat_template(
|
||||
tokenizer,
|
||||
chat_template = template_name,
|
||||
|
|
@ -1021,7 +992,7 @@ class InferenceBackend:
|
|||
except Exception as e:
|
||||
logger.warning(f"Could not apply get_chat_template: {e}")
|
||||
|
||||
# Step 2: Format with tokenizer.apply_chat_template()
|
||||
# Step 2: format with tokenizer.apply_chat_template()
|
||||
if system_prompt:
|
||||
template_messages = [{"role": "system", "content": system_prompt}] + messages
|
||||
else:
|
||||
|
|
@ -1046,10 +1017,10 @@ class InferenceBackend:
|
|||
logger.debug(f"Formatted prompt: {formatted_prompt[:200]}...")
|
||||
except Exception as e:
|
||||
logger.error(f"Error applying chat template: {e}")
|
||||
# Fallback to manual formatting
|
||||
# Fall back to manual formatting
|
||||
formatted_prompt = self.format_chat_prompt(messages, system_prompt)
|
||||
|
||||
# Step 3: Generate
|
||||
# Step 3: generate
|
||||
yield from self.generate_stream(
|
||||
formatted_prompt,
|
||||
temperature,
|
||||
|
|
@ -1080,7 +1051,7 @@ class InferenceBackend:
|
|||
model = model_info["model"]
|
||||
processor = model_info["processor"]
|
||||
# FastVisionModel may return a raw tokenizer (e.g. GemmaTokenizerFast)
|
||||
# instead of a Processor for some models. Safe unwrap for tokenize-only ops.
|
||||
# for some models. Safe unwrap for tokenize-only ops.
|
||||
raw_tokenizer = getattr(processor, "tokenizer", processor)
|
||||
|
||||
# Extract user message
|
||||
|
|
@ -1136,7 +1107,7 @@ class InferenceBackend:
|
|||
return_tensors = "pt",
|
||||
).to(model.device)
|
||||
else:
|
||||
# Text-only for vision model
|
||||
# Text-only path for a vision model
|
||||
formatted_prompt = self.format_chat_prompt(messages, system_prompt)
|
||||
inputs = raw_tokenizer(formatted_prompt, return_tensors = "pt").to(model.device)
|
||||
|
||||
|
|
@ -1233,7 +1204,7 @@ class InferenceBackend:
|
|||
repetition_penalty,
|
||||
cancel_event = None,
|
||||
) -> Generator[str, None, None]:
|
||||
"""Handle audio input (ASR) generation — accepts audio numpy array, streams text output.
|
||||
"""Audio-input (ASR) generation: takes an audio numpy array, streams text.
|
||||
|
||||
Uses processor.apply_chat_template with audio embedded in messages (Gemma 3n pattern).
|
||||
"""
|
||||
|
|
@ -1245,7 +1216,7 @@ class InferenceBackend:
|
|||
processor = model_info.get("processor") or model_info.get("tokenizer")
|
||||
raw_tokenizer = getattr(processor, "tokenizer", processor)
|
||||
|
||||
# Extract last user text — default matches notebook prompt
|
||||
# Last user text; default matches the notebook prompt
|
||||
user_text = "Please transcribe this audio."
|
||||
if messages:
|
||||
for msg in reversed(messages):
|
||||
|
|
@ -1253,11 +1224,11 @@ class InferenceBackend:
|
|||
user_text = msg["content"]
|
||||
break
|
||||
|
||||
# Use ASR-specific system prompt if user hasn't set a custom one
|
||||
# ASR-specific default system prompt if none set
|
||||
if not system_prompt:
|
||||
system_prompt = "You are an assistant that transcribes speech accurately."
|
||||
|
||||
# Build messages in Gemma 3n format — audio goes INTO apply_chat_template
|
||||
# Gemma 3n format — audio goes INTO apply_chat_template
|
||||
audio_messages = [
|
||||
{"role": "system", "content": [{"type": "text", "text": system_prompt}]},
|
||||
{
|
||||
|
|
@ -1269,7 +1240,7 @@ class InferenceBackend:
|
|||
},
|
||||
]
|
||||
|
||||
# apply_chat_template handles audio embedding + tokenization in one step
|
||||
# apply_chat_template does audio embedding + tokenization in one step
|
||||
inputs = processor.apply_chat_template(
|
||||
audio_messages,
|
||||
add_generation_prompt = True,
|
||||
|
|
@ -1290,7 +1261,7 @@ class InferenceBackend:
|
|||
timeout = 0.2,
|
||||
)
|
||||
|
||||
# Notebook uses do_sample=False for ASR (greedy decoding for accuracy)
|
||||
# Notebook uses do_sample=False (greedy) for ASR accuracy
|
||||
generation_kwargs = dict(
|
||||
**inputs,
|
||||
streamer = streamer,
|
||||
|
|
@ -1354,9 +1325,9 @@ class InferenceBackend:
|
|||
audio_array,
|
||||
cancel_event = None,
|
||||
) -> Generator[str, None, None]:
|
||||
"""Whisper ASR — takes audio numpy array, yields transcribed text.
|
||||
"""Whisper ASR: takes an audio numpy array, yields transcribed text.
|
||||
|
||||
Uses the pre-built transformers pipeline (created during model loading).
|
||||
Uses the pre-built transformers pipeline created at model load.
|
||||
"""
|
||||
model_info = self.models[self.active_model_name]
|
||||
whisper_pipe = model_info.get("whisper_pipeline")
|
||||
|
|
@ -1376,7 +1347,7 @@ class InferenceBackend:
|
|||
yield f"Error: {str(e)}"
|
||||
|
||||
def _is_gpt_oss_model(self, model_name: str = None) -> bool:
|
||||
"""Check if the given (or active) model uses the gpt-oss harmony protocol."""
|
||||
"""Whether the given (or active) model uses the gpt-oss harmony protocol."""
|
||||
from utils.datasets import is_gpt_oss_model_name
|
||||
return is_gpt_oss_model_name(model_name or self.active_model_name or "")
|
||||
|
||||
|
|
@ -1392,10 +1363,10 @@ class InferenceBackend:
|
|||
cancel_event = None,
|
||||
_adapter_state = None,
|
||||
) -> Generator[str, None, None]:
|
||||
"""Generate streaming text response (text models only).
|
||||
"""Generate a streaming text response (text models only).
|
||||
|
||||
_adapter_state: if not None, the background thread toggles adapters
|
||||
before model.generate(), all under _generation_lock.
|
||||
before model.generate(), under _generation_lock.
|
||||
"""
|
||||
if not self.active_model_name:
|
||||
yield "Error: No active model"
|
||||
|
|
@ -1403,9 +1374,9 @@ class InferenceBackend:
|
|||
|
||||
model_info = self.models[self.active_model_name]
|
||||
model = model_info["model"]
|
||||
# For VLMs the stored "tokenizer" is actually the processor.
|
||||
# Unwrap to get the real tokenizer so TextIteratorStreamer's
|
||||
# skip_prompt / skip_special_tokens work correctly.
|
||||
# For VLMs the stored "tokenizer" is actually the processor. Unwrap to
|
||||
# the real tokenizer so TextIteratorStreamer's skip_prompt /
|
||||
# skip_special_tokens work correctly.
|
||||
tokenizer = model_info["tokenizer"]
|
||||
tokenizer = getattr(tokenizer, "tokenizer", tokenizer)
|
||||
|
||||
|
|
@ -1415,8 +1386,8 @@ class InferenceBackend:
|
|||
from transformers import TextIteratorStreamer
|
||||
import threading
|
||||
|
||||
# Use HarmonyTextStreamer for gpt-oss models to properly parse
|
||||
# the multi-channel harmony protocol into <think> tags
|
||||
# gpt-oss models: HarmonyTextStreamer parses the multi-channel
|
||||
# harmony protocol into <think> tags
|
||||
if self._is_gpt_oss_model():
|
||||
try:
|
||||
streamer = HarmonyTextStreamer(
|
||||
|
|
@ -1513,11 +1484,10 @@ class InferenceBackend:
|
|||
cleaned = self._clean_generated_text(output)
|
||||
yield cleaned
|
||||
finally:
|
||||
# Only set cancel_event when we exited early (user cancel),
|
||||
# NOT on normal completion. cancel_event is a shared mp.Event
|
||||
# — setting it unconditionally would leave a stale cancel
|
||||
# signal that could interfere with the next serialized
|
||||
# generation request (e.g. in compare mode).
|
||||
# Set cancel_event only on early exit (user cancel), NOT on
|
||||
# normal completion. It's a shared mp.Event; setting it
|
||||
# unconditionally would leave a stale cancel signal that could
|
||||
# disrupt the next serialized request (e.g. compare mode).
|
||||
if cancel_event is not None and not generation_complete:
|
||||
cancel_event.set()
|
||||
thread.join(timeout = 10)
|
||||
|
|
@ -1544,10 +1514,8 @@ class InferenceBackend:
|
|||
repetition_penalty: float = 1.0,
|
||||
use_adapter: Optional[Union[bool, str]] = None,
|
||||
) -> Tuple[bytes, int]:
|
||||
"""
|
||||
Generate audio from text for TTS models.
|
||||
Returns (wav_bytes, sample_rate).
|
||||
Blocking — generates complete audio before returning.
|
||||
"""Generate audio from text for TTS models.
|
||||
Returns (wav_bytes, sample_rate). Blocking — full audio before return.
|
||||
"""
|
||||
if not self.active_model_name:
|
||||
raise RuntimeError("No active model")
|
||||
|
|
@ -1661,8 +1629,8 @@ class InferenceBackend:
|
|||
repetition_penalty,
|
||||
):
|
||||
"""Generate audio using DAC (OuteTTS). Follows Oute_TTS_(1B).ipynb exactly."""
|
||||
# Monkey-patch RepetitionPenaltyLogitsProcessor with a 64-token penalty
|
||||
# window (same as the OuteTTS notebook) to avoid degenerate repetition.
|
||||
# Monkey-patch RepetitionPenaltyLogitsProcessor with a 64-token window
|
||||
# (same as the OuteTTS notebook) to avoid degenerate repetition.
|
||||
self._patch_repetition_penalty_processor()
|
||||
|
||||
prompt = (
|
||||
|
|
@ -1689,10 +1657,9 @@ class InferenceBackend:
|
|||
|
||||
@classmethod
|
||||
def _patch_repetition_penalty_processor(cls):
|
||||
"""
|
||||
Monkey-patch transformers' RepetitionPenaltyLogitsProcessor with a
|
||||
64-token sliding window variant (from the OuteTTS notebook).
|
||||
Only applied once per process.
|
||||
"""Monkey-patch transformers' RepetitionPenaltyLogitsProcessor with a
|
||||
64-token sliding-window variant (from the OuteTTS notebook).
|
||||
Applied once per process.
|
||||
"""
|
||||
if cls._repetition_penalty_patched:
|
||||
return
|
||||
|
|
@ -1743,10 +1710,10 @@ class InferenceBackend:
|
|||
reasoning_effort: Optional[str] = None,
|
||||
preserve_thinking: Optional[bool] = None,
|
||||
) -> str:
|
||||
"""Render the chat prompt, peeling kwargs the template does not
|
||||
understand. Delegates to the dependency-light helper module so
|
||||
the fallback chain can be unit-tested without pulling unsloth /
|
||||
torch into the test sandbox.
|
||||
"""Render the chat prompt, peeling kwargs the template doesn't
|
||||
understand. Delegates to the dependency-light helper module so the
|
||||
fallback chain is unit-testable without pulling unsloth / torch into
|
||||
the test sandbox.
|
||||
"""
|
||||
from core.inference.chat_template_helpers import (
|
||||
apply_chat_template_for_generation,
|
||||
|
|
@ -1846,8 +1813,7 @@ class InferenceBackend:
|
|||
return self._format_generic_template(chat_messages, {})
|
||||
|
||||
def _format_chat_manual(self, messages: list, template_type: str, special_tokens: dict) -> str:
|
||||
"""
|
||||
Manual chat formatting fallback for when tokenizer template fails
|
||||
"""Manual chat-formatting fallback when the tokenizer template fails.
|
||||
|
||||
Args:
|
||||
messages: List of message dictionaries
|
||||
|
|
@ -1960,12 +1926,7 @@ class InferenceBackend:
|
|||
return formatted
|
||||
|
||||
def check_vision_model_compatibility(self) -> bool:
|
||||
"""
|
||||
Check if current model supports vision.
|
||||
|
||||
Returns:
|
||||
bool: True if current model supports vision, False otherwise
|
||||
"""
|
||||
"""Whether the current model supports vision."""
|
||||
current_model = self.get_current_model()
|
||||
if current_model and current_model in self.models:
|
||||
return self.models[current_model].get("is_vision", False)
|
||||
|
|
@ -1981,7 +1942,7 @@ class InferenceBackend:
|
|||
return
|
||||
|
||||
try:
|
||||
# This is a common pattern for Unsloth/Hugging Face models
|
||||
# Common pattern for Unsloth/Hugging Face models
|
||||
if hasattr(model, "past_key_values"):
|
||||
model.past_key_values = None
|
||||
if hasattr(model, "generation_config"):
|
||||
|
|
@ -1995,7 +1956,7 @@ class InferenceBackend:
|
|||
def reset_generation_state(self):
|
||||
"""Reset any cached generation state to prevent hanging after errors"""
|
||||
try:
|
||||
# Clear cached states for ALL loaded models
|
||||
# Clear cached state for ALL loaded models
|
||||
for model_name in self.models.keys():
|
||||
self._reset_model_generation_state(model_name)
|
||||
|
||||
|
|
@ -2029,9 +1990,9 @@ class InferenceBackend:
|
|||
def _clean_generated_text(self, text: str) -> str:
|
||||
"""Strip leaked special tokens using the tokenizer's own token list."""
|
||||
if self._is_gpt_oss_model():
|
||||
# HarmonyTextStreamer produces clean <think>...</think> output.
|
||||
# Strip harmony protocol tokens and other gpt-oss added tokens
|
||||
# (e.g. <|return|>) that may leak past the streamer.
|
||||
# HarmonyTextStreamer emits clean <think>...</think>. Strip any
|
||||
# harmony protocol tokens and other gpt-oss tokens (e.g.
|
||||
# <|return|>) that leak past the streamer.
|
||||
import re
|
||||
text = re.sub(r"<\|[a-z_]+\|>", "", text)
|
||||
return text.strip()
|
||||
|
|
@ -2059,7 +2020,7 @@ class InferenceBackend:
|
|||
try:
|
||||
from utils.datasets import MODEL_TO_TEMPLATE_MAPPER
|
||||
|
||||
# Try exact match first
|
||||
# Exact match first
|
||||
model_name_lower = model_name.lower()
|
||||
if model_name_lower in MODEL_TO_TEMPLATE_MAPPER:
|
||||
chat_template_info["template_name"] = MODEL_TO_TEMPLATE_MAPPER[model_name_lower]
|
||||
|
|
@ -2067,7 +2028,7 @@ class InferenceBackend:
|
|||
f"Detected template '{chat_template_info['template_name']}' for {model_name} from mapper"
|
||||
)
|
||||
else:
|
||||
# Try partial match (for variants like model_name-bnb-4bit)
|
||||
# Partial match (for variants like model_name-bnb-4bit)
|
||||
for key in MODEL_TO_TEMPLATE_MAPPER:
|
||||
if key in model_name_lower or model_name_lower in key:
|
||||
chat_template_info["template_name"] = MODEL_TO_TEMPLATE_MAPPER[key]
|
||||
|
|
@ -2127,15 +2088,15 @@ class InferenceBackend:
|
|||
logger.info(f"No built-in chat template for {model_name}, will use generic formatting")
|
||||
|
||||
def get_current_model(self) -> Optional[str]:
|
||||
"""Get currently active model name"""
|
||||
"""Currently active model name."""
|
||||
return self.active_model_name
|
||||
|
||||
def is_model_loading(self) -> bool:
|
||||
"""Check if any model is currently loading"""
|
||||
"""Whether any model is currently loading."""
|
||||
return len(self.loading_models) > 0
|
||||
|
||||
def get_loading_model(self) -> Optional[str]:
|
||||
"""Get name of currently loading model"""
|
||||
"""Name of the currently loading model."""
|
||||
return next(iter(self.loading_models)) if self.loading_models else None
|
||||
|
||||
def load_model_simple(
|
||||
|
|
@ -2145,9 +2106,8 @@ class InferenceBackend:
|
|||
max_seq_length: int = 2048,
|
||||
load_in_4bit: bool = True,
|
||||
) -> bool:
|
||||
"""
|
||||
Simple model loading wrapper for chat interface.
|
||||
Accepts model path as string and handles ModelConfig creation internally.
|
||||
"""Simple model-loading wrapper for the chat interface. Takes a string
|
||||
path and builds the ModelConfig internally.
|
||||
|
||||
Args:
|
||||
model_path: Model name or path (e.g., "unsloth/llama-3-8b")
|
||||
|
|
@ -2159,14 +2119,12 @@ class InferenceBackend:
|
|||
bool: True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Create config from string path
|
||||
config = ModelConfig.from_ui_selection(
|
||||
model_path,
|
||||
lora_path = None, # No LoRA for chat
|
||||
is_lora = False,
|
||||
)
|
||||
|
||||
# Call existing load_model with config
|
||||
return self.load_model(
|
||||
config = config,
|
||||
max_seq_length = max_seq_length,
|
||||
|
|
|
|||
|
|
@ -4,13 +4,13 @@
|
|||
"""
|
||||
RSA key pair for encrypting API keys in transit.
|
||||
|
||||
The frontend encrypts API keys with the server's public key before
|
||||
including them in requests. The backend decrypts with its private key
|
||||
before forwarding to external providers.
|
||||
The frontend encrypts API keys with the server's public key before sending
|
||||
them; the backend decrypts with its private key before forwarding to external
|
||||
providers.
|
||||
|
||||
The key pair is generated at server startup and lives only in memory —
|
||||
it is regenerated on each restart. The frontend fetches the public key
|
||||
via GET /api/providers/public-key on load.
|
||||
The key pair is generated at server startup, lives only in memory, and is
|
||||
regenerated on each restart. The frontend fetches the public key via
|
||||
GET /api/providers/public-key on load.
|
||||
"""
|
||||
|
||||
import base64
|
||||
|
|
@ -36,9 +36,9 @@ def init_key_pair() -> None:
|
|||
"""Generate an RSA-2048 key pair. Called once at server startup."""
|
||||
global _private_key, _public_key_pem, _public_key_fingerprint
|
||||
if _private_key is not None:
|
||||
# Re-entry is suspicious — every fresh keypair invalidates all
|
||||
# in-flight ciphertext encrypted against the previous public key.
|
||||
# Log loudly so a regression that calls init twice is visible.
|
||||
# Re-entry is suspicious — a fresh keypair invalidates all in-flight
|
||||
# ciphertext encrypted against the previous public key. Log loudly so a
|
||||
# regression that calls init twice is visible.
|
||||
logger.warning(
|
||||
"init_key_pair called again — replacing existing RSA keypair "
|
||||
"(previous fingerprint=%s). Any frontend that cached the old "
|
||||
|
|
@ -111,9 +111,9 @@ def decrypt_api_key(encrypted_b64: str) -> str:
|
|||
),
|
||||
)
|
||||
except Exception as exc:
|
||||
# Surface enough state to distinguish key mismatch (wrong public key
|
||||
# used on encrypt) from a padding/algo mismatch or corrupted bytes.
|
||||
# Expected ciphertext length for RSA-2048 is exactly 256 bytes.
|
||||
# Surface enough state to tell a key mismatch (wrong public key used on
|
||||
# encrypt) from a padding/algo mismatch or corrupted bytes. Expected
|
||||
# RSA-2048 ciphertext length is exactly 256 bytes.
|
||||
logger.warning(
|
||||
"decrypt_api_key: RSA decrypt failed (ciphertext_len=%d, expected=256, "
|
||||
"fingerprint=%s, exc=%s): %s",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -3,11 +3,10 @@
|
|||
|
||||
"""Boundary validator for user-supplied llama-server pass-through args.
|
||||
|
||||
Reject only flags Studio manages (model identity, auth, network,
|
||||
parallel slots). Everything else (sampling, ``-c``, ``-ngl``,
|
||||
``--flash-attn``, ``--cache-type-*``, ``--spec-*``, ``--jinja``, ...)
|
||||
is appended after Studio's auto-set flags so llama.cpp's last-wins
|
||||
parser lets the user override.
|
||||
Reject only flags Studio manages (model identity, auth, network, parallel
|
||||
slots). Everything else (sampling, ``-c``, ``-ngl``, ``--flash-attn``,
|
||||
``--cache-type-*``, ``--spec-*``, ``--jinja``, ...) is appended after
|
||||
Studio's auto-set flags so llama.cpp's last-wins parser lets the user override.
|
||||
|
||||
Ref: https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md
|
||||
"""
|
||||
|
|
@ -19,11 +18,11 @@ from typing import Iterable, Optional
|
|||
# Each group = every alias (short + long) of one hard-denied flag.
|
||||
# Extend the matching group when llama.cpp adds a new alias.
|
||||
_DENYLIST_GROUPS: tuple[frozenset[str], ...] = (
|
||||
# Parallel slots: owned by typer --parallel; a pass-through would
|
||||
# desync app.state.llama_parallel_slots from llama-server.
|
||||
# Parallel slots: owned by typer --parallel; a pass-through would desync
|
||||
# app.state.llama_parallel_slots from llama-server.
|
||||
frozenset({"-np", "--parallel", "--n-parallel"}),
|
||||
# Model identity: Studio resolves it from LoadRequest; a second
|
||||
# -m would load a different model than Studio thinks it loaded.
|
||||
# Model identity: Studio resolves it from LoadRequest; a second -m would
|
||||
# load a different model than Studio thinks it loaded.
|
||||
frozenset({"-m", "--model"}),
|
||||
frozenset({"-mu", "--model-url"}),
|
||||
frozenset({"-dr", "--docker-repo"}),
|
||||
|
|
@ -40,15 +39,15 @@ _DENYLIST_GROUPS: tuple[frozenset[str], ...] = (
|
|||
frozenset({"--path"}),
|
||||
frozenset({"--api-prefix"}),
|
||||
frozenset({"--reuse-port"}),
|
||||
# Auth / TLS: Studio terminates auth; upstream --api-key / TLS
|
||||
# shadows Studio's key and breaks the proxy hop.
|
||||
# Auth / TLS: Studio terminates auth; upstream --api-key / TLS shadows
|
||||
# Studio's key and breaks the proxy hop.
|
||||
frozenset({"--api-key"}),
|
||||
frozenset({"--api-key-file"}),
|
||||
frozenset({"--ssl-key-file"}),
|
||||
frozenset({"--ssl-cert-file"}),
|
||||
# Built-in web UI. --webui/--no-webui is the legacy spelling;
|
||||
# upstream renamed to --ui/--no-ui + --ui-*. Keep both so prebuilt
|
||||
# and system llama.cpp binaries both match.
|
||||
# Built-in web UI. --webui/--no-webui is the legacy spelling; upstream
|
||||
# renamed to --ui/--no-ui + --ui-*. Keep both so prebuilt and system
|
||||
# llama.cpp binaries match.
|
||||
frozenset({"--webui", "--no-webui"}),
|
||||
frozenset({"--ui", "--no-ui"}),
|
||||
frozenset({"--ui-config"}),
|
||||
|
|
@ -62,8 +61,8 @@ _DENYLIST_GROUPS: tuple[frozenset[str], ...] = (
|
|||
# those endpoints, breaking Studio's /v1/chat/completions hop.
|
||||
frozenset({"--embedding", "--embeddings"}),
|
||||
frozenset({"--rerank", "--reranking"}),
|
||||
# llama-server's own built-in tools flag would silently stack on top
|
||||
# of Studio's --enable-tools / --disable-tools policy resolver.
|
||||
# llama-server's own built-in tools flag would silently stack on top of
|
||||
# Studio's --enable-tools / --disable-tools policy resolver.
|
||||
frozenset({"--tools"}),
|
||||
)
|
||||
|
||||
|
|
@ -74,10 +73,9 @@ def _flag_name(token: str) -> Optional[str]:
|
|||
"""Flag name for ``token``, or None if it isn't a flag.
|
||||
|
||||
Peels `--key=value` to `--key`, treats `-1` / `-0.5` as values
|
||||
(llama-server shorts always start with a letter), strips
|
||||
whitespace, and normalises attached `-np8` / signed `-np-1` /
|
||||
digit-prefix-junk `-np8x` to `-np`. Mirrors the CLI's
|
||||
`_expand_attached_np_short`.
|
||||
(llama-server shorts always start with a letter), strips whitespace, and
|
||||
normalises attached `-np8` / signed `-np-1` / digit-prefix-junk `-np8x`
|
||||
to `-np`. Mirrors the CLI's `_expand_attached_np_short`.
|
||||
"""
|
||||
token = token.strip()
|
||||
if not token.startswith("-") or token in {"-", "--"}:
|
||||
|
|
@ -95,9 +93,9 @@ def _flag_name(token: str) -> Optional[str]:
|
|||
|
||||
|
||||
def validate_extra_args(args: Optional[Iterable[str]]) -> list[str]:
|
||||
"""Validate user-supplied llama-server args. Returns a flat list
|
||||
ready to extend the llama-server command; raises ``ValueError``
|
||||
naming the offending flag on the first managed token."""
|
||||
"""Validate user-supplied llama-server args. Returns a flat list ready to
|
||||
extend the llama-server command; raises ``ValueError`` naming the
|
||||
offending flag on the first managed token."""
|
||||
if not args:
|
||||
return []
|
||||
out: list[str] = []
|
||||
|
|
@ -116,15 +114,15 @@ def validate_extra_args(args: Optional[Iterable[str]]) -> list[str]:
|
|||
|
||||
|
||||
def is_managed_flag(flag: str) -> bool:
|
||||
"""True if ``flag`` is Studio-managed. Normalises via ``_flag_name``
|
||||
so `-np8` / `--parallel=8` classify like the canonical tokens."""
|
||||
"""True if ``flag`` is Studio-managed. Normalises via ``_flag_name`` so
|
||||
`-np8` / `--parallel=8` classify like the canonical tokens."""
|
||||
normalised = _flag_name(flag)
|
||||
return normalised is not None and normalised in _DENYLIST
|
||||
|
||||
|
||||
# Pass-through flags that shadow first-class LoadRequest fields;
|
||||
# stripped from inherited extras so they can't last-wins-override an
|
||||
# Apply that re-sets the same field.
|
||||
# Pass-through flags that shadow first-class LoadRequest fields; stripped
|
||||
# from inherited extras so they can't last-wins-override an Apply that
|
||||
# re-sets the same field.
|
||||
_CONTEXT_FLAGS: frozenset[str] = frozenset({"-c", "--ctx-size"})
|
||||
_CACHE_FLAGS: frozenset[str] = frozenset({"-ctk", "--cache-type-k", "-ctv", "--cache-type-v"})
|
||||
_SPEC_FLAGS: frozenset[str] = frozenset(
|
||||
|
|
@ -157,16 +155,15 @@ _TEMPLATE_FLAGS: frozenset[str] = frozenset(
|
|||
|
||||
_SHADOWING_FLAGS: frozenset[str] = _CONTEXT_FLAGS | _CACHE_FLAGS | _SPEC_FLAGS | _TEMPLATE_FLAGS
|
||||
|
||||
# Shadowing flags that take no value -- strip the flag only, never the
|
||||
# following token.
|
||||
# Shadowing flags that take no value -- strip the flag only, not the next token.
|
||||
_BOOLEAN_SHADOWING_FLAGS: frozenset[str] = frozenset({"--spec-default", "--jinja", "--no-jinja"})
|
||||
|
||||
|
||||
def parse_ctx_override(args: Optional[Iterable[str]]) -> Optional[int]:
|
||||
"""Return the last user-supplied ``-c`` / ``--ctx-size`` value.
|
||||
|
||||
Mirrors llama.cpp's last-wins flag parsing for the one pass-through
|
||||
numeric knob Studio's load-time fit logic needs to see.
|
||||
Mirrors llama.cpp's last-wins parsing for the one pass-through numeric
|
||||
knob Studio's load-time fit logic needs to see.
|
||||
"""
|
||||
if not args:
|
||||
return None
|
||||
|
|
@ -204,10 +201,10 @@ def parse_ctx_override(args: Optional[Iterable[str]]) -> Optional[int]:
|
|||
def resolve_requested_ctx(args: Optional[Iterable[str]], fallback_n_ctx: int) -> int:
|
||||
"""Return the context size load_model should treat as requested.
|
||||
|
||||
Single source of truth for the two-line ``ctx_override = parse_ctx_override(...);
|
||||
requested_ctx = ctx_override if ctx_override is not None else n_ctx`` pattern
|
||||
used by ``load_model`` so tests don't have to reimplement the conditional
|
||||
locally and then assert against their own reimplementation.
|
||||
Single source of truth for the ``ctx_override = parse_ctx_override(...);
|
||||
requested_ctx = ctx_override if ctx_override is not None else n_ctx``
|
||||
pattern used by ``load_model``, so tests don't reimplement the
|
||||
conditional and assert against their own reimplementation.
|
||||
"""
|
||||
override = parse_ctx_override(args)
|
||||
return override if override is not None else fallback_n_ctx
|
||||
|
|
@ -216,10 +213,10 @@ def resolve_requested_ctx(args: Optional[Iterable[str]], fallback_n_ctx: int) ->
|
|||
def parse_cache_override(args: Optional[Iterable[str]]) -> Optional[str]:
|
||||
"""Return the last-wins cache type if extras pass cache flags.
|
||||
|
||||
Mirrors parse_ctx_override but for cache type. Recognises both -ctk
|
||||
(key) and -ctv (value). When both flags appear, returns the last-wins
|
||||
value, treating key and value cache flags as the same setting because
|
||||
Studio's KV estimate has a single cache_type_kv knob.
|
||||
Like parse_ctx_override but for cache type. Recognises -ctk (key) and
|
||||
-ctv (value); when both appear, returns the last-wins value, treating
|
||||
key and value flags as one setting because Studio's KV estimate has a
|
||||
single cache_type_kv knob.
|
||||
"""
|
||||
if not args:
|
||||
return None
|
||||
|
|
@ -256,8 +253,7 @@ def resolve_cache_type_kv(
|
|||
) -> Optional[str]:
|
||||
"""Return the cache type load_model should treat as requested.
|
||||
|
||||
Single source of truth for the cache override conditional used by
|
||||
``load_model``.
|
||||
Single source of truth for ``load_model``'s cache override conditional.
|
||||
"""
|
||||
override = parse_cache_override(args)
|
||||
return override if override is not None else fallback_cache_type_kv
|
||||
|
|
@ -274,10 +270,9 @@ def strip_shadowing_flags(
|
|||
"""Strip flags that shadow first-class Studio settings.
|
||||
|
||||
Used when inheriting a previous load's ``llama_extra_args`` so an
|
||||
inherited `-c 4096` can't override the current `max_seq_length`
|
||||
(same for cache / spec / template). Each ``strip_*`` toggle
|
||||
controls one group; the route only strips groups whose first-class
|
||||
field the caller actually supplied.
|
||||
inherited `-c 4096` can't override the current `max_seq_length` (same for
|
||||
cache / spec / template). Each ``strip_*`` toggle controls one group; the
|
||||
route only strips groups whose first-class field the caller supplied.
|
||||
"""
|
||||
shadowing: set[str] = set()
|
||||
if strip_context:
|
||||
|
|
@ -299,8 +294,8 @@ def strip_shadowing_flags(
|
|||
out.append(tok)
|
||||
i += 1
|
||||
continue
|
||||
# Drop the flag; consume the next token too unless it's
|
||||
# boolean, already inline (`-c=4096`), or another flag.
|
||||
# Drop the flag; also consume the next token unless it's boolean,
|
||||
# already inline (`-c=4096`), or another flag.
|
||||
if flag in _BOOLEAN_SHADOWING_FLAGS or "=" in tok:
|
||||
i += 1
|
||||
elif i + 1 < n and _flag_name(tokens[i + 1]) is None:
|
||||
|
|
|
|||
|
|
@ -31,20 +31,20 @@ def parse_stdio_command(address: str) -> list[str]:
|
|||
posix = sys.platform != "win32"
|
||||
parts = shlex.split(address, posix = posix)
|
||||
if not posix:
|
||||
# posix=False keeps backslash paths intact but also keeps the surrounding
|
||||
# quotes on a token. Strip a matched pair so the argv reaches the
|
||||
# subprocess clean ('"C:\\Program Files\\node"' -> C:\\Program Files\\node).
|
||||
# posix=False keeps backslash paths intact but also keeps surrounding
|
||||
# quotes on a token. Strip a matched pair so argv reaches the subprocess
|
||||
# clean ('"C:\\Program Files\\node"' -> C:\\Program Files\\node).
|
||||
parts = [p[1:-1] if len(p) >= 2 and p[0] == p[-1] and p[0] in "\"'" else p for p in parts]
|
||||
return parts
|
||||
|
||||
|
||||
def stdio_mcp_enabled() -> bool:
|
||||
"""stdio MCP servers spawn local processes as the backend user (and bypass
|
||||
the python/terminal sandbox), so they are only allowed when the backend
|
||||
host is the user's own machine. The Tauri desktop app sets
|
||||
"""stdio MCP servers spawn local processes as the backend user (bypassing
|
||||
the python/terminal sandbox), so they're allowed only when the backend host
|
||||
is the user's own machine. The Tauri desktop app sets
|
||||
UNSLOTH_STUDIO_ALLOW_STDIO_MCP=1 (see main.py); advanced localhost /
|
||||
self-hosted users can opt in with the same variable. It stays off for
|
||||
Colab and any network (0.0.0.0) bind."""
|
||||
self-hosted users can opt in with the same variable. Stays off for Colab
|
||||
and any network (0.0.0.0) bind."""
|
||||
return os.environ.get("UNSLOTH_STUDIO_ALLOW_STDIO_MCP") == "1"
|
||||
|
||||
|
||||
|
|
@ -63,8 +63,8 @@ def probe_timeout(address: str, use_oauth: bool) -> float:
|
|||
|
||||
|
||||
def parse_server_headers(server: dict) -> Optional[dict]:
|
||||
"""Parsed headers_json. For stdio servers this dict is the process
|
||||
environment instead of HTTP headers (see _client)."""
|
||||
"""Parsed headers_json. For stdio servers this dict is the process env
|
||||
instead of HTTP headers (see _client)."""
|
||||
raw = server.get("headers_json")
|
||||
if not raw:
|
||||
return None
|
||||
|
|
@ -83,7 +83,7 @@ def _oauth_store():
|
|||
from utils.paths.storage_roots import ensure_dir, studio_root
|
||||
|
||||
# Hash keys/collections — fastmcp uses raw URLs like https://x.com as
|
||||
# keys and FileTreeStore would treat the "://" as nested directories.
|
||||
# keys, and FileTreeStore would treat the "://" as nested directories.
|
||||
_oauth_token_store = FileTreeStore(
|
||||
data_directory = ensure_dir(studio_root() / "mcp-oauth-tokens"),
|
||||
key_sanitization_strategy = AlwaysHashStrategy(),
|
||||
|
|
@ -93,12 +93,11 @@ def _oauth_store():
|
|||
|
||||
|
||||
async def clear_oauth_tokens_async(url: str) -> None:
|
||||
"""Drop any persisted OAuth tokens for ``url``. fastmcp keys tokens by
|
||||
MCP URL, so on server delete / URL change / OAuth disable we have to
|
||||
clear the old credentials explicitly. Otherwise re-registering the
|
||||
same URL would silently reuse the old account's token. The entire
|
||||
body runs inside the protected block -- store / OAuth construction
|
||||
failing must not make the delete / update route 500."""
|
||||
"""Drop any persisted OAuth tokens for ``url``. fastmcp keys tokens by MCP
|
||||
URL, so on server delete / URL change / OAuth disable we must clear old
|
||||
credentials explicitly; otherwise re-registering the same URL silently
|
||||
reuses the old account's token. The whole body is protected -- store / OAuth
|
||||
construction failing must not 500 the delete / update route."""
|
||||
try:
|
||||
from fastmcp.client.auth import OAuth
|
||||
auth = OAuth(mcp_url = url, token_storage = _oauth_store())
|
||||
|
|
@ -124,9 +123,9 @@ def _client(
|
|||
parts = parse_stdio_command(url)
|
||||
if not parts:
|
||||
raise ValueError(f"Empty stdio command: {url!r}")
|
||||
# env vars ride the headers field (merged over the SDK's safe default env).
|
||||
# keep_alive=False tears the subprocess down on exit, so a one-shot
|
||||
# probe/tool call never leaves an orphan process.
|
||||
# env vars ride the headers field (merged over the SDK's safe default
|
||||
# env). keep_alive=False tears the subprocess down on exit, so a
|
||||
# one-shot probe/tool call never leaves an orphan.
|
||||
return Client(
|
||||
StdioTransport(
|
||||
command = parts[0],
|
||||
|
|
@ -192,10 +191,10 @@ def call_tool_sync(
|
|||
) -> str:
|
||||
"""Synchronously call an MCP tool.
|
||||
|
||||
``cancel_event``: optional ``threading.Event``. When set, the in-flight
|
||||
HTTP call is cancelled and the function returns a cancellation Error.
|
||||
Polled in parallel with the tool call via ``asyncio.wait`` so a /cancel
|
||||
POST from the UI interrupts even mid-network-read.
|
||||
``cancel_event``: optional ``threading.Event``. When set, the in-flight HTTP
|
||||
call is cancelled and a cancellation Error is returned. Polled in parallel
|
||||
with the tool call via ``asyncio.wait`` so a /cancel POST from the UI
|
||||
interrupts even mid-network-read.
|
||||
"""
|
||||
|
||||
async def _call() -> Any:
|
||||
|
|
@ -204,14 +203,14 @@ def call_tool_sync(
|
|||
|
||||
async def _watch_cancel() -> None:
|
||||
# 50 ms cadence keeps cancellation responsive without busy-looping;
|
||||
# matches the cadence routes/inference.py uses for cancel watchers.
|
||||
# matches routes/inference.py's cancel watcher cadence.
|
||||
while cancel_event is not None and not cancel_event.is_set():
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
async def _race() -> Any:
|
||||
# Check cancellation before spawning the call task so a pre-set
|
||||
# event short-circuits before opening the transport / HTTP
|
||||
# connection (reviewer-reproduced race).
|
||||
# Check cancellation before spawning the call task so a pre-set event
|
||||
# short-circuits before opening the transport / HTTP connection
|
||||
# (reviewer-reproduced race).
|
||||
if cancel_event is not None and cancel_event.is_set():
|
||||
raise _MCPCancelled
|
||||
call_task = asyncio.create_task(_call())
|
||||
|
|
|
|||
|
|
@ -65,10 +65,10 @@ class MLXInferenceBackend:
|
|||
def _configure_memory_limits(self):
|
||||
"""Apply Metal memory caps before loading a model.
|
||||
|
||||
Mirrors MLXTrainer._configure_memory_limits's defaults:
|
||||
Mirrors MLXTrainer._configure_memory_limits defaults:
|
||||
memory_limit = 85% of recommended working-set,
|
||||
wired_limit = min(recommended, memory_limit). Recorded so unload
|
||||
can lower wired_limit back to release pinned RAM.
|
||||
wired_limit = min(recommended, memory_limit). Recorded so unload can
|
||||
lower wired_limit back to release pinned RAM.
|
||||
"""
|
||||
import mlx.core as mx
|
||||
|
||||
|
|
@ -109,18 +109,16 @@ class MLXInferenceBackend:
|
|||
model_name = config.identifier if hasattr(config, "identifier") else str(config)
|
||||
is_vision = getattr(config, "is_vision", False)
|
||||
|
||||
# GGUF guard. GGUF models are served via llama-server in the
|
||||
# parent process, NOT via mlx-lm in this MLX subprocess. The
|
||||
# route at studio/backend/routes/inference.py:592 (`if config.
|
||||
# is_gguf:`) is responsible for sending GGUF traffic to the
|
||||
# llama-server backend before reaching the MLX orchestrator.
|
||||
# If we end up here with is_gguf=True, the route's
|
||||
# `detect_gguf_model_remote` returned None on its first call
|
||||
# (transient HF Hub flake) but the subprocess re-detection
|
||||
# succeeded. The subprocess cannot reach into the parent's
|
||||
# llama-server, so all we can do is raise loudly so the caller
|
||||
# gets a clear error instead of a cryptic
|
||||
# "config.json does not exist" from mlx_lm.utils.load_model.
|
||||
# GGUF guard. GGUF models are served via llama-server in the parent
|
||||
# process, NOT via mlx-lm in this MLX subprocess. The route at
|
||||
# studio/backend/routes/inference.py:592 (`if config.is_gguf:`) sends
|
||||
# GGUF traffic to llama-server before reaching the MLX orchestrator.
|
||||
# Reaching here with is_gguf=True means the route's
|
||||
# `detect_gguf_model_remote` returned None on its first call (transient
|
||||
# HF Hub flake) but the subprocess re-detection succeeded. The
|
||||
# subprocess can't reach the parent's llama-server, so raise loudly to
|
||||
# give the caller a clear error instead of a cryptic "config.json does
|
||||
# not exist" from mlx_lm.utils.load_model.
|
||||
if getattr(config, "is_gguf", False):
|
||||
raise RuntimeError(
|
||||
f"MLXInferenceBackend cannot load GGUF model '{model_name}': "
|
||||
|
|
@ -187,9 +185,9 @@ class MLXInferenceBackend:
|
|||
"audio_type": None,
|
||||
"has_audio_input": False,
|
||||
}
|
||||
# Capture chat_template_info so the worker IPC reply can ship
|
||||
# it back to the parent and the route layer classifies
|
||||
# capabilities the same way as the transformers / GGUF paths.
|
||||
# Capture chat_template_info so the worker IPC reply can ship it back
|
||||
# to the parent and the route layer classifies capabilities the same
|
||||
# way as the transformers / GGUF paths.
|
||||
self._populate_chat_template_info(model_name)
|
||||
|
||||
logger.info("Model %s loaded successfully", model_name)
|
||||
|
|
@ -198,10 +196,9 @@ class MLXInferenceBackend:
|
|||
def _populate_chat_template_info(self, model_name: str) -> None:
|
||||
"""Mirror InferenceBackend._load_chat_template_info for MLX.
|
||||
|
||||
Stores ``chat_template_info`` on ``self.models[model_name]``
|
||||
with the resolved ``tokenizer.chat_template`` so
|
||||
``_detect_safetensors_features`` (route layer) sees the same
|
||||
template the model actually uses."""
|
||||
Stores ``chat_template_info`` on ``self.models[model_name]`` with the
|
||||
resolved ``tokenizer.chat_template`` so ``_detect_safetensors_features``
|
||||
(route layer) sees the template the model actually uses."""
|
||||
entry = self.models.get(model_name)
|
||||
if not entry:
|
||||
return
|
||||
|
|
@ -276,10 +273,9 @@ class MLXInferenceBackend:
|
|||
max_new_tokens = 256,
|
||||
repetition_penalty = 1.0,
|
||||
cancel_event = None,
|
||||
# Reasoning / tool kwargs forwarded by the route + worker -- the
|
||||
# MLX path renders the template via apply_chat_template_for_
|
||||
# generation so these are honoured the same way as the
|
||||
# transformers path.
|
||||
# Reasoning / tool kwargs forwarded by the route + worker -- the MLX
|
||||
# path renders the template via apply_chat_template_for_generation so
|
||||
# these are honoured the same way as the transformers path.
|
||||
tools = None,
|
||||
enable_thinking = None,
|
||||
reasoning_effort = None,
|
||||
|
|
@ -308,7 +304,7 @@ class MLXInferenceBackend:
|
|||
{"type": "text", "text": content},
|
||||
]
|
||||
elif isinstance(content, list):
|
||||
# Prepend image if not already there
|
||||
# Prepend image if not already present
|
||||
has_image = any(
|
||||
p.get("type") == "image" for p in content if isinstance(p, dict)
|
||||
)
|
||||
|
|
@ -389,8 +385,8 @@ class MLXInferenceBackend:
|
|||
min_p = float(min_p or 0.0),
|
||||
min_tokens_to_keep = 1,
|
||||
)
|
||||
# Only build a logits processor when we actually have a non-trivial
|
||||
# repetition penalty (1.0 is the no-op value).
|
||||
# Only build a logits processor for a non-trivial repetition penalty
|
||||
# (1.0 is the no-op value).
|
||||
logits_processors = None
|
||||
if repetition_penalty is not None and float(repetition_penalty) not in (
|
||||
0.0,
|
||||
|
|
@ -425,7 +421,7 @@ class MLXInferenceBackend:
|
|||
):
|
||||
final_response = response
|
||||
token_ids.append(response.token)
|
||||
# Decode full sequence with skip_special_tokens — same as GPU
|
||||
# Decode full sequence with skip_special_tokens — like GPU
|
||||
cumulative = self._tokenizer.decode(
|
||||
token_ids,
|
||||
skip_special_tokens = True,
|
||||
|
|
@ -471,10 +467,9 @@ class MLXInferenceBackend:
|
|||
apply_chat_template_for_generation,
|
||||
)
|
||||
|
||||
# Pick the chat-template-aware caller: processors that expose
|
||||
# their own apply_chat_template + chat_template attr (e.g.
|
||||
# Qwen2.5-VL) use it directly; otherwise fall back to the
|
||||
# nested tokenizer.
|
||||
# Pick the chat-template-aware caller: processors that expose their own
|
||||
# apply_chat_template + chat_template attr (e.g. Qwen2.5-VL) use it
|
||||
# directly; otherwise fall back to the nested tokenizer.
|
||||
chat_target = self._processor
|
||||
if (
|
||||
getattr(self._processor, "apply_chat_template", None) is None
|
||||
|
|
@ -492,8 +487,8 @@ class MLXInferenceBackend:
|
|||
preserve_thinking = preserve_thinking,
|
||||
)
|
||||
|
||||
# For VLM: always use mlx_vlm's stream_generate which handles
|
||||
# pixel_values properly (passes None for text-only, image for VLM)
|
||||
# For VLM: always use mlx_vlm's stream_generate, which handles
|
||||
# pixel_values properly (None for text-only, image for VLM)
|
||||
images = [image] if image is not None else None
|
||||
|
||||
cumulative = ""
|
||||
|
|
@ -503,8 +498,8 @@ class MLXInferenceBackend:
|
|||
image is not None,
|
||||
)
|
||||
# mlx_vlm.stream_generate forwards **kwargs into generate_step, which
|
||||
# accepts temp/top_p/top_k/repetition_penalty (and builds the sampler
|
||||
# + logits_processors internally). Pass them through.
|
||||
# accepts temp/top_p/top_k/repetition_penalty (building the sampler +
|
||||
# logits_processors internally). Pass them through.
|
||||
# NOTE: mlx_vlm.generate_step expects ``temperature=`` (long form) —
|
||||
# passing ``temp=`` silently falls into **kwargs and is ignored,
|
||||
# leaving generation stuck at the default 0.0 (greedy).
|
||||
|
|
|
|||
|
|
@ -4,13 +4,12 @@
|
|||
"""
|
||||
Inference orchestrator — subprocess-based.
|
||||
|
||||
Provides the same API as InferenceBackend, but delegates all ML work
|
||||
to a persistent subprocess. The subprocess is spawned on first model load
|
||||
and stays alive for subsequent requests.
|
||||
Same API as InferenceBackend, but delegates all ML work to a persistent
|
||||
subprocess spawned on first model load and reused for later requests.
|
||||
|
||||
When switching between models that need different transformers versions
|
||||
(e.g. GLM-4.7-Flash needs 5.x, Qwen needs 4.57.x), the old subprocess
|
||||
is killed and a new one is spawned with the correct version.
|
||||
When switching between models needing different transformers versions
|
||||
(e.g. GLM-4.7-Flash needs 5.x, Qwen needs 4.57.x), the old subprocess is
|
||||
killed and a new one spawned with the correct version.
|
||||
|
||||
Pattern follows core/training/training.py.
|
||||
"""
|
||||
|
|
@ -51,9 +50,8 @@ class InferenceOrchestrator:
|
|||
"""
|
||||
Inference backend orchestrator — subprocess-based.
|
||||
|
||||
Exposes the same API surface as InferenceBackend so routes/inference.py
|
||||
needs minimal changes. Internally, all heavy ML operations happen in
|
||||
a persistent subprocess.
|
||||
Same API surface as InferenceBackend (so routes/inference.py needs
|
||||
minimal changes); all heavy ML work happens in a persistent subprocess.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
|
|
@ -66,9 +64,9 @@ class InferenceOrchestrator:
|
|||
self._gen_lock = threading.Lock() # Serializes generation — one request at a time
|
||||
|
||||
# Dispatcher state — for compare mode (adapter-controlled requests).
|
||||
# Instead of serializing via _gen_lock, adapter-controlled requests
|
||||
# send commands directly to the subprocess and read from per-request
|
||||
# mailboxes. A dispatcher thread routes resp_queue events by request_id.
|
||||
# These bypass _gen_lock: they send commands directly and read from
|
||||
# per-request mailboxes. A dispatcher thread routes resp_queue events
|
||||
# by request_id.
|
||||
self._mailboxes: dict[str, queue.Queue] = {}
|
||||
self._mailbox_lock = threading.Lock() # Protects _mailboxes dict
|
||||
self._dispatcher_thread: Optional[threading.Thread] = None
|
||||
|
|
@ -92,7 +90,7 @@ class InferenceOrchestrator:
|
|||
atexit.register(self._cleanup)
|
||||
logger.info("InferenceOrchestrator initialized (subprocess mode)")
|
||||
|
||||
# Kick off background fetch of top models from HF
|
||||
# Background fetch of top models from HF
|
||||
threading.Thread(target = self._fetch_top_models, daemon = True, name = "top-models").start()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
@ -105,10 +103,9 @@ class InferenceOrchestrator:
|
|||
self._top_models_ready.wait(timeout = 5)
|
||||
top_gguf = self._top_gguf_cache or []
|
||||
top_hub = self._top_hub_cache or []
|
||||
# Curated static defaults first (editorial picks like new models),
|
||||
# then HF download-ranked models to backfill.
|
||||
# Send extras so the frontend still has 4 per category
|
||||
# after removing already-downloaded models.
|
||||
# Curated static defaults first (editorial picks), then HF
|
||||
# download-ranked models to backfill. Send extras so the frontend
|
||||
# still has 4 per category after removing downloaded ones.
|
||||
result: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for m in self._static_models + top_gguf + top_hub:
|
||||
|
|
@ -133,8 +130,8 @@ class InferenceOrchestrator:
|
|||
)
|
||||
if resp.status_code == 200:
|
||||
models = resp.json()
|
||||
# Top 40 GGUFs - frontend pages through them on-demand via
|
||||
# infinite scroll, so we send a deep pool.
|
||||
# Top 40 GGUFs - frontend pages through them via infinite
|
||||
# scroll, so send a deep pool.
|
||||
gguf_ids = [m["id"] for m in models if m.get("id", "").upper().endswith("-GGUF")][
|
||||
:40
|
||||
]
|
||||
|
|
@ -201,7 +198,7 @@ class InferenceOrchestrator:
|
|||
self._cancel_generation()
|
||||
time.sleep(0.5) # Brief wait for generation to stop
|
||||
|
||||
# 2. Drain stale responses from queue
|
||||
# 2. Drain stale responses
|
||||
self._drain_queue()
|
||||
|
||||
# 3. Send shutdown command
|
||||
|
|
@ -243,7 +240,7 @@ class InferenceOrchestrator:
|
|||
self._shutdown_subprocess(timeout = 5.0)
|
||||
|
||||
def _ensure_subprocess_alive(self) -> bool:
|
||||
"""Check if subprocess is alive."""
|
||||
"""True if the subprocess is alive."""
|
||||
return self._proc is not None and self._proc.is_alive()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
@ -277,14 +274,12 @@ class InferenceOrchestrator:
|
|||
) -> dict:
|
||||
"""Block until a response of the expected type arrives.
|
||||
|
||||
Also handles 'status' and 'error' events during the wait.
|
||||
Returns the matching response dict.
|
||||
Raises RuntimeError on timeout or subprocess crash.
|
||||
Also handles 'status' and 'error' events during the wait. Returns the
|
||||
matching response dict; raises RuntimeError on timeout or crash.
|
||||
|
||||
The *timeout* is an **inactivity** timeout: it resets whenever the
|
||||
subprocess sends a status message, so long-running operations (large
|
||||
downloads, slow model loads) won't be killed as long as the subprocess
|
||||
keeps reporting progress.
|
||||
*timeout* is an **inactivity** timeout: it resets on each status
|
||||
message, so long-running operations (large downloads, slow loads)
|
||||
survive as long as the subprocess keeps reporting progress.
|
||||
"""
|
||||
deadline = time.monotonic() + timeout
|
||||
|
||||
|
|
@ -345,8 +340,8 @@ class InferenceOrchestrator:
|
|||
def _drain_until_gen_done(self, timeout: float = 5.0) -> None:
|
||||
"""Consume resp_queue events until gen_done/gen_error, discarding them.
|
||||
|
||||
Called after cancel to ensure stale tokens from the cancelled
|
||||
generation don't leak into the next request.
|
||||
Called after cancel so stale tokens from the cancelled generation
|
||||
don't leak into the next request.
|
||||
"""
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
|
|
@ -367,10 +362,9 @@ class InferenceOrchestrator:
|
|||
def _start_dispatcher(self) -> None:
|
||||
"""Start the dispatcher thread if not already running.
|
||||
|
||||
The dispatcher reads from the shared resp_queue and routes
|
||||
responses to per-request mailbox queues. This allows multiple
|
||||
adapter-controlled (compare) requests to be in-flight without
|
||||
holding _gen_lock.
|
||||
The dispatcher reads the shared resp_queue and routes responses to
|
||||
per-request mailbox queues, letting multiple adapter-controlled
|
||||
(compare) requests be in-flight without holding _gen_lock.
|
||||
"""
|
||||
if self._dispatcher_thread is not None and self._dispatcher_thread.is_alive():
|
||||
return
|
||||
|
|
@ -422,9 +416,8 @@ class InferenceOrchestrator:
|
|||
mbox.put(resp)
|
||||
continue
|
||||
|
||||
# No matching mailbox — might be for a _gen_lock reader or orphaned
|
||||
# Push it back so _read_resp can pick it up. But we can't un-get
|
||||
# from mp.Queue, so log a warning.
|
||||
# No matching mailbox — maybe for a _gen_lock reader or orphaned.
|
||||
# Can't un-get from mp.Queue, so just log a warning.
|
||||
if rtype not in ("status",):
|
||||
logger.debug(
|
||||
"Dispatcher: no mailbox for request_id=%s type=%s, dropping",
|
||||
|
|
@ -453,13 +446,13 @@ class InferenceOrchestrator:
|
|||
) -> Generator[str, None, None]:
|
||||
"""Dispatched generation — sends command without holding _gen_lock.
|
||||
|
||||
Uses a per-request mailbox to receive tokens. This allows two
|
||||
compare-mode requests to be queued in the subprocess simultaneously,
|
||||
eliminating the inter-generation round-trip overhead.
|
||||
Uses a per-request mailbox for tokens, so two compare-mode requests
|
||||
can be queued in the subprocess at once, avoiding the
|
||||
inter-generation round-trip overhead.
|
||||
|
||||
The subprocess processes commands sequentially from its cmd_queue,
|
||||
so generation is still serialized at the GPU level — we just avoid
|
||||
the orchestrator-level lock contention.
|
||||
The subprocess runs commands sequentially from its cmd_queue, so
|
||||
generation stays serialized at the GPU level — this only avoids
|
||||
orchestrator-level lock contention.
|
||||
"""
|
||||
if not self._ensure_subprocess_alive():
|
||||
yield "Error: Inference subprocess is not running"
|
||||
|
|
@ -532,7 +525,7 @@ class InferenceOrchestrator:
|
|||
rtype = resp.get("type", "")
|
||||
|
||||
if rtype == "token":
|
||||
# Check cancel from route (e.g. SSE connection closed)
|
||||
# Cancel from route (e.g. SSE connection closed)
|
||||
if cancel_event is not None and cancel_event.is_set():
|
||||
self._cancel_generation()
|
||||
# Drain remaining events for this request
|
||||
|
|
@ -574,8 +567,8 @@ class InferenceOrchestrator:
|
|||
def _wait_dispatcher_idle(self) -> None:
|
||||
"""Wait for all dispatched requests to complete, then stop dispatcher.
|
||||
|
||||
Called by _generate_inner before using the _gen_lock path, to ensure
|
||||
the dispatcher thread isn't competing for resp_queue reads.
|
||||
Called by _generate_inner before the _gen_lock path so the dispatcher
|
||||
thread isn't competing for resp_queue reads.
|
||||
"""
|
||||
if self._dispatcher_thread is None or not self._dispatcher_thread.is_alive():
|
||||
return
|
||||
|
|
@ -588,9 +581,9 @@ class InferenceOrchestrator:
|
|||
break
|
||||
time.sleep(0.1)
|
||||
|
||||
# Only stop dispatcher if all mailboxes drained. If compare
|
||||
# requests are still active, leave the dispatcher running so
|
||||
# their token routing isn't killed mid-stream.
|
||||
# Only stop dispatcher if all mailboxes drained. If compare requests
|
||||
# are still active, leave it running so their token routing isn't
|
||||
# killed mid-stream.
|
||||
with self._mailbox_lock:
|
||||
still_active = bool(self._mailboxes)
|
||||
if still_active:
|
||||
|
|
@ -618,9 +611,9 @@ class InferenceOrchestrator:
|
|||
) -> bool:
|
||||
"""Load a model for inference.
|
||||
|
||||
Always spawns a fresh subprocess for each model load. This ensures
|
||||
a clean Python interpreter — no stale unsloth patches, torch.compile
|
||||
caches, or inspect.getsource() failures from a previous model.
|
||||
Always spawns a fresh subprocess per load for a clean interpreter —
|
||||
no stale unsloth patches, torch.compile caches, or
|
||||
inspect.getsource() failures from a previous model.
|
||||
"""
|
||||
from utils.transformers_version import needs_transformers_5
|
||||
|
||||
|
|
@ -649,9 +642,9 @@ class InferenceOrchestrator:
|
|||
sub_config["resolved_gpu_ids"] = resolved_gpu_ids
|
||||
sub_config["gpu_selection"] = gpu_selection
|
||||
|
||||
# Always kill existing subprocess and spawn fresh.
|
||||
# Reusing a subprocess after unsloth patches torch internals
|
||||
# causes inspect.getsource() failures on the next model load.
|
||||
# Always kill the existing subprocess and spawn fresh: reusing one
|
||||
# after unsloth patches torch internals causes
|
||||
# inspect.getsource() failures on the next load.
|
||||
if self._ensure_subprocess_alive():
|
||||
self._cancel_generation()
|
||||
time.sleep(0.3)
|
||||
|
|
@ -680,7 +673,7 @@ class InferenceOrchestrator:
|
|||
try:
|
||||
resp = self._wait_response("loaded")
|
||||
except DownloadStallError:
|
||||
# First stall and Xet was enabled -> retry with Xet disabled
|
||||
# First stall with Xet enabled -> retry with Xet disabled
|
||||
if attempt == 0 and not disable_xet:
|
||||
logger.warning(
|
||||
"Download stalled for '%s' -- retrying with HF_HUB_DISABLE_XET=1",
|
||||
|
|
@ -689,7 +682,7 @@ class InferenceOrchestrator:
|
|||
self._shutdown_subprocess(timeout = 5)
|
||||
disable_xet = True
|
||||
continue
|
||||
# Second stall (or already had xet disabled) -> give up
|
||||
# Second stall (or xet already disabled) -> give up
|
||||
self._shutdown_subprocess(timeout = 5)
|
||||
raise RuntimeError(
|
||||
f"Download stalled for '{model_name}' even with "
|
||||
|
|
@ -745,7 +738,7 @@ class InferenceOrchestrator:
|
|||
return True
|
||||
|
||||
if not self._ensure_subprocess_alive():
|
||||
# No subprocess — just clear local state
|
||||
# No subprocess — clear local state
|
||||
self.models.pop(model_name, None)
|
||||
if self.active_model_name == model_name:
|
||||
self.active_model_name = None
|
||||
|
|
@ -797,13 +790,13 @@ class InferenceOrchestrator:
|
|||
"""Generate response, streaming tokens from subprocess.
|
||||
|
||||
Optional ``tools`` / ``enable_thinking`` / ``reasoning_effort`` /
|
||||
``preserve_thinking`` kwargs are forwarded into the worker so
|
||||
``preserve_thinking`` kwargs are forwarded to the worker so
|
||||
``tokenizer.apply_chat_template`` can render tool schemas and
|
||||
reasoning controls when the template understands them.
|
||||
reasoning controls when the template supports them.
|
||||
|
||||
``stats_holder``: caller-owned dict; on gen_done its "stats" key
|
||||
receives the worker's usage/timings. Request-scoped by design so
|
||||
concurrent streams cannot read each other's stats.
|
||||
``stats_holder``: caller-owned dict; on gen_done its "stats" key gets
|
||||
the worker's usage/timings. Request-scoped so concurrent streams cannot
|
||||
read each other's stats.
|
||||
"""
|
||||
yield from self._generate_inner(
|
||||
messages = messages,
|
||||
|
|
@ -847,13 +840,12 @@ class InferenceOrchestrator:
|
|||
stats_holder: Optional[dict] = None,
|
||||
**_unused,
|
||||
):
|
||||
"""Run the safetensors agentic tool loop in this (parent)
|
||||
process, calling the worker for each generation turn.
|
||||
"""Run the safetensors agentic tool loop in this (parent) process,
|
||||
calling the worker for each generation turn.
|
||||
|
||||
Yields the same event dicts as the GGUF tool loop so the route
|
||||
layer can stream both backends through one helper. See
|
||||
``safetensors_agentic.run_safetensors_tool_loop`` for the
|
||||
event protocol.
|
||||
Yields the same event dicts as the GGUF tool loop so the route layer
|
||||
can stream both backends through one helper. See
|
||||
``safetensors_agentic.run_safetensors_tool_loop`` for the protocol.
|
||||
"""
|
||||
from core.inference.safetensors_agentic import run_safetensors_tool_loop
|
||||
from core.inference.tools import execute_tool
|
||||
|
|
@ -861,8 +853,8 @@ class InferenceOrchestrator:
|
|||
max_new_tokens = max_tokens if max_tokens and max_tokens > 0 else 2048
|
||||
|
||||
def _single_turn(conv: list):
|
||||
# ``conv`` already carries any system message because the
|
||||
# loop appends to a list seeded with system+user above.
|
||||
# ``conv`` already carries any system message: the loop appends
|
||||
# to a list seeded with system+user above.
|
||||
common_kwargs = dict(
|
||||
messages = conv,
|
||||
system_prompt = "",
|
||||
|
|
@ -914,9 +906,9 @@ class InferenceOrchestrator:
|
|||
) -> Generator[str, None, None]:
|
||||
"""Generate with adapter control, streaming tokens from subprocess.
|
||||
|
||||
Uses the dispatcher path (no _gen_lock) so that compare-mode
|
||||
requests don't block each other. The subprocess naturally
|
||||
serializes them via its sequential command loop.
|
||||
Uses the dispatcher path (no _gen_lock) so compare-mode requests
|
||||
don't block each other; the subprocess serializes them via its
|
||||
sequential command loop.
|
||||
"""
|
||||
yield from self._generate_dispatched(
|
||||
use_adapter = use_adapter,
|
||||
|
|
@ -946,9 +938,8 @@ class InferenceOrchestrator:
|
|||
) -> Generator[str, None, None]:
|
||||
"""Inner generation logic — sends command to subprocess, yields tokens.
|
||||
|
||||
Serialized by _gen_lock: only one generation runs at a time.
|
||||
This prevents concurrent readers from consuming each other's
|
||||
tokens off the shared resp_queue.
|
||||
Serialized by _gen_lock (one generation at a time) so concurrent
|
||||
readers don't consume each other's tokens off the shared resp_queue.
|
||||
"""
|
||||
if not self._ensure_subprocess_alive():
|
||||
yield "Error: Inference subprocess is not running"
|
||||
|
|
@ -959,13 +950,13 @@ class InferenceOrchestrator:
|
|||
return
|
||||
|
||||
# If the dispatcher is running (from a previous compare-mode request),
|
||||
# wait for all dispatched requests to finish, then stop the dispatcher
|
||||
# so we can safely read from resp_queue directly.
|
||||
# wait for dispatched requests to finish then stop it, so we can read
|
||||
# from resp_queue directly.
|
||||
self._wait_dispatcher_idle()
|
||||
|
||||
# Serialize generation — single GPU, one generation at a time.
|
||||
# Without this lock, two concurrent readers on the same resp_queue
|
||||
# can consume and drop each other's token events.
|
||||
# Serialize generation — single GPU, one generation at a time. Without
|
||||
# this lock, two concurrent readers on the same resp_queue can consume
|
||||
# and drop each other's token events.
|
||||
with self._gen_lock:
|
||||
yield from self._generate_locked(
|
||||
messages = messages,
|
||||
|
|
@ -1029,8 +1020,8 @@ class InferenceOrchestrator:
|
|||
|
||||
if use_adapter is not None:
|
||||
cmd["use_adapter"] = use_adapter
|
||||
# Only forward template kwargs the caller actually set so older
|
||||
# workers that ignore unknown keys still work.
|
||||
# Only forward template kwargs the caller set, so older workers that
|
||||
# ignore unknown keys still work.
|
||||
if tools is not None:
|
||||
cmd["tools"] = tools
|
||||
if enable_thinking is not None:
|
||||
|
|
@ -1046,8 +1037,8 @@ class InferenceOrchestrator:
|
|||
yield f"Error: {exc}"
|
||||
return
|
||||
|
||||
# Yield tokens from response queue — we are the only reader
|
||||
# because _gen_lock is held.
|
||||
# Yield tokens from response queue — we are the only reader since
|
||||
# _gen_lock is held.
|
||||
while True:
|
||||
resp = self._read_resp(timeout = 30.0)
|
||||
|
||||
|
|
@ -1071,10 +1062,10 @@ class InferenceOrchestrator:
|
|||
return
|
||||
|
||||
if rtype == "token":
|
||||
# Check cancel from route (e.g. SSE connection closed)
|
||||
# Cancel from route (e.g. SSE connection closed)
|
||||
if cancel_event is not None and cancel_event.is_set():
|
||||
self._cancel_generation()
|
||||
# Wait for the subprocess to acknowledge cancellation
|
||||
# Wait for the subprocess to ack cancellation
|
||||
# (gen_done/gen_error) so stale events don't leak into
|
||||
# the next generation request.
|
||||
self._drain_until_gen_done(timeout = 5.0)
|
||||
|
|
@ -1117,7 +1108,7 @@ class InferenceOrchestrator:
|
|||
) -> Tuple[bytes, int]:
|
||||
"""Generate TTS audio. Returns (wav_bytes, sample_rate).
|
||||
|
||||
Blocking — sends command and waits for the complete audio response.
|
||||
Blocking — sends command and waits for the full audio response.
|
||||
"""
|
||||
if not self._ensure_subprocess_alive():
|
||||
raise RuntimeError("Inference subprocess is not running")
|
||||
|
|
@ -1242,7 +1233,7 @@ class InferenceOrchestrator:
|
|||
|
||||
request_id = str(uuid.uuid4())
|
||||
|
||||
# Convert numpy array to list for mp.Queue serialization
|
||||
# numpy array -> list for mp.Queue serialization
|
||||
audio_data = (
|
||||
audio_array.tolist() if hasattr(audio_array, "tolist") else list(audio_array)
|
||||
)
|
||||
|
|
@ -1310,8 +1301,8 @@ class InferenceOrchestrator:
|
|||
img,
|
||||
max_size: int = 800,
|
||||
):
|
||||
"""Resize image while maintaining aspect ratio.
|
||||
No ML imports needed — runs locally in parent process.
|
||||
"""Resize image preserving aspect ratio.
|
||||
No ML imports — runs locally in the parent process.
|
||||
"""
|
||||
if img is None:
|
||||
return None
|
||||
|
|
@ -1331,26 +1322,26 @@ class InferenceOrchestrator:
|
|||
return base64.b64encode(buf.getvalue()).decode("ascii")
|
||||
|
||||
def get_current_model(self) -> Optional[str]:
|
||||
"""Get currently active model name."""
|
||||
"""Currently active model name."""
|
||||
return self.active_model_name
|
||||
|
||||
def is_model_loading(self) -> bool:
|
||||
"""Check if any model is currently loading."""
|
||||
"""True if any model is loading."""
|
||||
return len(self.loading_models) > 0
|
||||
|
||||
def get_loading_model(self) -> Optional[str]:
|
||||
"""Get name of currently loading model."""
|
||||
"""Name of the currently loading model."""
|
||||
return next(iter(self.loading_models)) if self.loading_models else None
|
||||
|
||||
def check_vision_model_compatibility(self) -> bool:
|
||||
"""Check if current model supports vision."""
|
||||
"""True if the current model supports vision."""
|
||||
if self.active_model_name and self.active_model_name in self.models:
|
||||
return self.models[self.active_model_name].get("is_vision", False)
|
||||
return False
|
||||
|
||||
def _is_gpt_oss_model(self, model_name: str = None) -> bool:
|
||||
"""Parent-side gpt-oss detection so the safetensors route can run
|
||||
the same guard without an IPC round-trip to the subprocess."""
|
||||
"""Parent-side gpt-oss detection so the safetensors route can run the
|
||||
same guard without an IPC round-trip to the subprocess."""
|
||||
from utils.datasets import is_gpt_oss_model_name
|
||||
return is_gpt_oss_model_name(model_name or self.active_model_name or "")
|
||||
|
||||
|
|
@ -1360,7 +1351,7 @@ _inference_backend = None
|
|||
|
||||
|
||||
def get_inference_backend() -> InferenceOrchestrator:
|
||||
"""Get global inference backend instance (orchestrator)."""
|
||||
"""Global inference backend instance (orchestrator)."""
|
||||
global _inference_backend
|
||||
if _inference_backend is None:
|
||||
_inference_backend = InferenceOrchestrator()
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Static per-MTok pricing tables and ``calculate_cost`` helper for
|
||||
turning an upstream ``usage`` block into a USD figure.
|
||||
"""Per-MTok pricing tables and ``calculate_cost`` (usage block -> USD).
|
||||
|
||||
Sources: Anthropic prompt-caching docs (5m write 1.25x, 1h write 2x,
|
||||
read 0.1x), web search ($10/1000), code execution; OpenAI pricing page.
|
||||
|
|
@ -12,13 +11,13 @@ from __future__ import annotations
|
|||
|
||||
from typing import Any, Optional
|
||||
|
||||
# Per-MTok base pricing in USD. Cache multipliers are applied ON
|
||||
# `input_per_mtok` (not absolute prices), matching Anthropic's docs.
|
||||
# Per-MTok base USD. Cache multipliers apply to `input_per_mtok`
|
||||
# (not absolute prices), per Anthropic docs.
|
||||
ANTHROPIC_PRICING: dict[str, dict[str, float]] = {
|
||||
"claude-opus-4-7": {"input_per_mtok": 5.0, "output_per_mtok": 25.0},
|
||||
"claude-opus-4-6": {"input_per_mtok": 5.0, "output_per_mtok": 25.0},
|
||||
# Alias both the bare id and dated id: backend defaults reference
|
||||
# the bare form, which won't prefix-match the dated key.
|
||||
# Alias bare + dated id: backend defaults use the bare form, which
|
||||
# won't prefix-match the dated key.
|
||||
"claude-opus-4-5": {"input_per_mtok": 5.0, "output_per_mtok": 25.0},
|
||||
"claude-opus-4-5-20251101": {"input_per_mtok": 5.0, "output_per_mtok": 25.0},
|
||||
"claude-opus-4-1": {"input_per_mtok": 15.0, "output_per_mtok": 75.0},
|
||||
|
|
@ -34,8 +33,8 @@ ANTHROPIC_PRICING: dict[str, dict[str, float]] = {
|
|||
|
||||
OPENAI_PRICING: dict[str, dict[str, float]] = {
|
||||
# Verified against developers.openai.com/api/docs/pricing.
|
||||
# `long_context_*` keys apply once input exceeds the threshold
|
||||
# (gpt-5.5/5.4: 272k); families without these keys ship a single rate.
|
||||
# `long_context_*` keys apply past the threshold (gpt-5.5/5.4: 272k);
|
||||
# families without them ship a single rate.
|
||||
"gpt-5.5": {
|
||||
"input_per_mtok": 5.0,
|
||||
"output_per_mtok": 30.0,
|
||||
|
|
@ -58,28 +57,28 @@ OPENAI_PRICING: dict[str, dict[str, float]] = {
|
|||
# chat-latest aliases gpt-5.5.
|
||||
"gpt-5.3-chat-latest": {"input_per_mtok": 5.0, "output_per_mtok": 30.0},
|
||||
"chat-latest": {"input_per_mtok": 5.0, "output_per_mtok": 30.0},
|
||||
# o-series and gpt-4.5 are no longer on the pricing page; omit them
|
||||
# so calculate_cost returns priced=False rather than silently $0.
|
||||
# o-series / gpt-4.5 left off the pricing page: omit so calculate_cost
|
||||
# returns priced=False instead of silently $0.
|
||||
}
|
||||
|
||||
# Shared multipliers (same across every Anthropic model).
|
||||
# Shared multipliers (all Anthropic models).
|
||||
ANTHROPIC_CACHE_5M_WRITE_MULT = 1.25
|
||||
ANTHROPIC_CACHE_1H_WRITE_MULT = 2.0
|
||||
ANTHROPIC_CACHE_READ_MULT = 0.1
|
||||
# Anthropic fast-mode (Opus 4.6 / 4.7 only): 6x standard on input + output.
|
||||
# Anthropic fast-mode (Opus 4.6/4.7 only): 6x on input + output.
|
||||
# https://platform.claude.com/docs/en/build-with-claude/fast-mode#pricing
|
||||
ANTHROPIC_FAST_MODE_MULT = 6.0
|
||||
|
||||
# OpenAI: cache reads 0.1x; cache writes pay normal input price.
|
||||
# OpenAI: cache reads 0.1x; cache writes pay input price.
|
||||
OPENAI_CACHE_READ_MULT = 0.1
|
||||
|
||||
# Server-tool surcharges. Anthropic code_exec is $0.05/hr marginal
|
||||
# (50 free hours/day per org, not visible here).
|
||||
# Server-tool surcharges. Anthropic code_exec: $0.05/hr marginal
|
||||
# (50 free hours/day per org, not shown here).
|
||||
ANTHROPIC_WEB_SEARCH_USD_PER_1K = 10.0
|
||||
ANTHROPIC_CODE_EXEC_USD_PER_HOUR = 0.05
|
||||
|
||||
# OpenAI container bills per memory tier; we report the 1g default
|
||||
# ($0.09/hour) since the tier isn't surfaced to the cost ledger.
|
||||
# OpenAI container bills per memory tier; report the 1g default
|
||||
# ($0.09/hr) since the tier isn't surfaced to the ledger.
|
||||
OPENAI_WEB_SEARCH_USD_PER_1K = 10.0
|
||||
OPENAI_CONTAINER_USD_PER_HOUR = 0.09 # 1g default tier
|
||||
|
||||
|
|
@ -96,10 +95,9 @@ def _lookup(provider: str, model: str) -> Optional[dict[str, float]]:
|
|||
return None
|
||||
if model in table:
|
||||
return table[model]
|
||||
# Longest-prefix match on a dash boundary: lets dated snapshots
|
||||
# inherit canonical prices while preventing "claude-opus-4-15"
|
||||
# from matching "claude-opus-4-1" or "gpt-5.5-prod" from matching
|
||||
# "gpt-5.5-pro". Sort longest-first to pick the most specific row.
|
||||
# Longest-prefix match on a dash boundary, longest-first: dated
|
||||
# snapshots inherit canonical prices, but "claude-opus-4-15" won't
|
||||
# match "claude-opus-4-1" nor "gpt-5.5-prod" match "gpt-5.5-pro".
|
||||
for key in sorted(table, key = len, reverse = True):
|
||||
if model.startswith(key) and (len(model) == len(key) or model[len(key)] == "-"):
|
||||
return table[key]
|
||||
|
|
@ -107,11 +105,10 @@ def _lookup(provider: str, model: str) -> Optional[dict[str, float]]:
|
|||
|
||||
|
||||
def calculate_cost(provider: str, model: str, usage: dict[str, Any]) -> dict[str, float]:
|
||||
"""Return a per-turn USD cost breakdown with per-bucket + total
|
||||
fields so the frontend can render either a single number or a
|
||||
tooltip without re-doing the math. When the model isn't in the
|
||||
static table, ``priced`` is False and USD fields are 0.0 (token
|
||||
counts still report).
|
||||
"""Return a per-turn USD cost breakdown (per-bucket + total) so the
|
||||
frontend renders a number or tooltip without redoing the math.
|
||||
Unknown model -> ``priced`` False and USD fields 0.0 (token counts
|
||||
still report).
|
||||
"""
|
||||
prices = _lookup(provider, model)
|
||||
out: dict[str, float] = {
|
||||
|
|
@ -128,22 +125,20 @@ def calculate_cost(provider: str, model: str, usage: dict[str, Any]) -> dict[str
|
|||
}
|
||||
|
||||
# Accept raw (input_tokens/output_tokens) and Studio chat-style
|
||||
# (prompt_tokens/completion_tokens) envelopes. Cache buckets
|
||||
# behave differently per envelope:
|
||||
# (prompt_tokens/completion_tokens) envelopes. Cache buckets differ:
|
||||
# raw Anthropic: input_tokens EXCLUDES cache buckets
|
||||
# raw OpenAI: input_tokens INCLUDES cache_read
|
||||
# Studio Anthropic: prompt_tokens INCLUDES cache_creation + cache_read
|
||||
# Studio OpenAI: prompt_tokens == raw input_tokens
|
||||
# Clamp tokens >=0 so corrupted payloads can't produce a negative bill.
|
||||
# Clamp >=0 so corrupted payloads can't produce a negative bill.
|
||||
cache_creation = max(0, int(usage.get("cache_creation_input_tokens") or 0))
|
||||
cache_read_native_present = (
|
||||
"cache_read_input_tokens" in usage and usage.get("cache_read_input_tokens") is not None
|
||||
)
|
||||
cache_read = max(0, int(usage.get("cache_read_input_tokens") or 0))
|
||||
# Fallback to mirrored prompt_tokens_details only when the native
|
||||
# cache_read_input_tokens key is absent. An explicit native 0 is
|
||||
# authoritative, so a stale mirrored block from a proxy can never
|
||||
# inflate cache_read past the native count.
|
||||
# Fall back to mirrored prompt_tokens_details only when native
|
||||
# cache_read_input_tokens is absent; an explicit native 0 is
|
||||
# authoritative, so a stale proxy mirror can't inflate cache_read.
|
||||
if not cache_read_native_present:
|
||||
details = usage.get("prompt_tokens_details") or {}
|
||||
if isinstance(details, dict):
|
||||
|
|
@ -152,22 +147,22 @@ def calculate_cost(provider: str, model: str, usage: dict[str, Any]) -> dict[str
|
|||
if has_input_tokens:
|
||||
input_tokens = max(0, int(usage.get("input_tokens") or 0))
|
||||
else:
|
||||
# Chat-style: peel cache buckets back out for Anthropic to
|
||||
# recover the raw uncached prompt count.
|
||||
# Chat-style: peel cache buckets back out for Anthropic to get
|
||||
# the raw uncached prompt count.
|
||||
prompt_tokens = max(0, int(usage.get("prompt_tokens") or 0))
|
||||
if provider == "anthropic":
|
||||
input_tokens = max(0, prompt_tokens - cache_creation - cache_read)
|
||||
else:
|
||||
input_tokens = prompt_tokens
|
||||
# Prefer raw output_tokens even when 0 (an `or` fallback would
|
||||
# silently pick a stale completion_tokens).
|
||||
# Prefer raw output_tokens even when 0 (an `or` would pick a stale
|
||||
# completion_tokens).
|
||||
if "output_tokens" in usage and usage.get("output_tokens") is not None:
|
||||
output_tokens = max(0, int(usage.get("output_tokens") or 0))
|
||||
else:
|
||||
output_tokens = max(0, int(usage.get("completion_tokens") or 0))
|
||||
if provider == "openai":
|
||||
# Cached tokens land on either input_tokens_details (raw
|
||||
# Responses) or prompt_tokens_details (Studio chat-style).
|
||||
# Cached tokens land on input_tokens_details (raw Responses) or
|
||||
# prompt_tokens_details (Studio chat-style).
|
||||
for key in ("input_tokens_details", "prompt_tokens_details"):
|
||||
details = usage.get(key) or {}
|
||||
if isinstance(details, dict):
|
||||
|
|
@ -200,8 +195,8 @@ def calculate_cost(provider: str, model: str, usage: dict[str, Any]) -> dict[str
|
|||
out_per = prices["output_per_mtok"]
|
||||
|
||||
# Anthropic fast-mode: 6x on input + output. Cache multipliers stack
|
||||
# on top of fast-mode, so applying once to (base, out_per) propagates
|
||||
# into the cache_*_usd buckets computed below.
|
||||
# on top, so applying once to (base, out_per) flows into the
|
||||
# cache_*_usd buckets below.
|
||||
if provider == "anthropic" and usage.get("speed") == "fast":
|
||||
base *= ANTHROPIC_FAST_MODE_MULT
|
||||
out_per *= ANTHROPIC_FAST_MODE_MULT
|
||||
|
|
@ -235,15 +230,15 @@ def calculate_cost(provider: str, model: str, usage: dict[str, Any]) -> dict[str
|
|||
+ code_exec_hours * ANTHROPIC_CODE_EXEC_USD_PER_HOUR
|
||||
)
|
||||
else:
|
||||
# OpenAI: cache writes pay base input; only cache reads get
|
||||
# 0.1x. Subtract cached from already-counted input_usd to
|
||||
# avoid double-billing (OpenAI folds cache into input_tokens).
|
||||
# OpenAI: cache writes pay base input, only reads get 0.1x.
|
||||
# Subtract cached from already-counted input_usd to avoid
|
||||
# double-billing (OpenAI folds cache into input_tokens).
|
||||
if cache_read > 0:
|
||||
non_cached_input = max(0, input_tokens - cache_read)
|
||||
out["input_usd"] = (non_cached_input / 1_000_000.0) * base
|
||||
out["cache_read_usd"] = (cache_read / 1_000_000.0) * base * OPENAI_CACHE_READ_MULT
|
||||
# OpenAI server-tool surcharges arrive under `openai_tool_use`
|
||||
# (normalised by the SSE finaliser from output array items).
|
||||
# (normalised by the SSE finaliser from output items).
|
||||
srv = usage.get("openai_tool_use") or {}
|
||||
if isinstance(srv, dict):
|
||||
web_searches = int(srv.get("web_search_requests") or 0)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
Static registry of supported external LLM providers.
|
||||
|
||||
All providers expose OpenAI-compatible /v1/chat/completions endpoints
|
||||
with Bearer token authentication and SSE streaming support.
|
||||
with Bearer token auth and SSE streaming.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
|
@ -26,11 +26,10 @@ PROVIDER_REGISTRY: dict[str, dict[str, Any]] = {
|
|||
"supports_tool_calling": True,
|
||||
"auth_header": "Authorization",
|
||||
"auth_prefix": "Bearer ",
|
||||
# Keep the model picker scoped to the current generation. The remote
|
||||
# /v1/models listing returns dozens of historical snapshots, fine-tunes
|
||||
# and non-chat models (embeddings, TTS, image, moderation) that we
|
||||
# never want to surface in the chat UI. Filtering here so backend
|
||||
# is the single source of truth.
|
||||
# Scope the picker to the current generation. /v1/models returns
|
||||
# dozens of historical snapshots, fine-tunes, and non-chat models
|
||||
# (embeddings, TTS, image, moderation) we never want in the chat
|
||||
# UI. Filter here so the backend is the single source of truth.
|
||||
"model_id_allowlist": re.compile(r"^(gpt-5\.[345]|gpt-4\.5|o3)(?:[-.]|$)"),
|
||||
# Hide dated snapshots and the retired plain gpt-5.3 id.
|
||||
"model_id_denylist": re.compile(r"^(gpt-5\.3)$|-\d{4}-\d{2}-\d{2}$"),
|
||||
|
|
@ -48,9 +47,8 @@ PROVIDER_REGISTRY: dict[str, dict[str, Any]] = {
|
|||
],
|
||||
# Anthropic /v1/models returns dated snapshot ids alongside the
|
||||
# canonical names (e.g. claude-3-5-sonnet-20241022). Hide the
|
||||
# YYYYMMDD-suffixed variants from the picker — same intent as the
|
||||
# OpenAI denylist, just a different date format (no dashes between
|
||||
# year/month/day).
|
||||
# YYYYMMDD-suffixed variants — same intent as the OpenAI denylist,
|
||||
# just a different date format (no dashes).
|
||||
"model_id_denylist": re.compile(r"-\d{8}$"),
|
||||
"supports_streaming": True,
|
||||
"supports_vision": True,
|
||||
|
|
@ -68,20 +66,18 @@ PROVIDER_REGISTRY: dict[str, dict[str, Any]] = {
|
|||
# Native Gemini REST endpoint -- the Gemini API does NOT speak
|
||||
# OpenAI Chat Completions on this base. Requests/responses are
|
||||
# translated in `_stream_gemini` in external_provider.py.
|
||||
# API reference: https://ai.google.dev/gemini-api/docs
|
||||
# https://ai.google.dev/gemini-api/docs
|
||||
"base_url": "https://generativelanguage.googleapis.com/v1beta",
|
||||
# Curated lineup -- the live ListModels response returns dozens
|
||||
# of historical / experimental / embedding ids. Cap to the
|
||||
# current chat-capable Gemini families (3.5 / 3.1 / 3 Flash /
|
||||
# 2.5) plus the Nano Banana image trio and the rolling
|
||||
# `*-latest` aliases. Excluded on purpose:
|
||||
# Curated lineup -- live ListModels returns dozens of historical /
|
||||
# experimental / embedding ids. Cap to current chat-capable Gemini
|
||||
# families (3.5 / 3.1 / 3 Flash / 2.5) plus the Nano Banana image
|
||||
# trio and the rolling `*-latest` aliases. Excluded on purpose:
|
||||
# - `gemini-2.0-flash*` (Google retired 2026-06-01; 404 on use)
|
||||
# - `gemini-3-pro-preview` (shut down 2026-03-09; auto-redirects
|
||||
# to `gemini-3.1-pro-preview` per Google's deprecation notice,
|
||||
# so we surface 3.1 directly and skip the redirect).
|
||||
# The allowlist below blocks the retired ids from re-appearing
|
||||
# via the live ListModels fetch. Verified against the live
|
||||
# `/v1beta/models` catalog 2026-05-24.
|
||||
# The allowlist below keeps the retired ids from re-appearing via
|
||||
# live ListModels. Verified against `/v1beta/models` 2026-05-24.
|
||||
"default_models": [
|
||||
"gemini-3.1-pro-preview",
|
||||
"gemini-3.5-flash",
|
||||
|
|
@ -100,8 +96,8 @@ PROVIDER_REGISTRY: dict[str, dict[str, Any]] = {
|
|||
"supports_streaming": True,
|
||||
"supports_vision": True,
|
||||
"supports_tool_calling": True,
|
||||
# The native API takes the API key on the `x-goog-api-key`
|
||||
# header. An empty `auth_prefix` ensures we send the bare key.
|
||||
# Native API takes the key on `x-goog-api-key`; empty `auth_prefix`
|
||||
# sends the bare key.
|
||||
"auth_header": "x-goog-api-key",
|
||||
"auth_prefix": "",
|
||||
"openai_compatible": False,
|
||||
|
|
@ -110,20 +106,19 @@ PROVIDER_REGISTRY: dict[str, dict[str, Any]] = {
|
|||
"API key from https://aistudio.google.com/apikey. "
|
||||
"See https://ai.google.dev/gemini-api/docs for endpoint shapes."
|
||||
),
|
||||
# Even after the regex match, drop ids that Google still
|
||||
# returns from ListModels but routes via implicit redirect.
|
||||
# gemini-3-pro-preview was shut down 2026-03-09 and is
|
||||
# auto-aliased to gemini-3.1-pro-preview; we surface the
|
||||
# canonical id only so users do not see two cards for the
|
||||
# same underlying model.
|
||||
# Even after the regex match, drop ids Google still returns from
|
||||
# ListModels but routes via implicit redirect. gemini-3-pro-preview
|
||||
# was shut down 2026-03-09 and auto-aliased to
|
||||
# gemini-3.1-pro-preview; surface the canonical id only so users
|
||||
# don't see two cards for the same model.
|
||||
"model_id_deny_exact": ("gemini-3-pro-preview",),
|
||||
# Matches the chat-capable 3.5 / 3.1 / 3 / 2.5 families plus the
|
||||
# rolling *-latest aliases (which Google rolls forward as new
|
||||
# generations ship). Image-tier ids (`-image`, `-image-preview`,
|
||||
# rolling *-latest aliases (rolled forward as new generations ship).
|
||||
# Image-tier ids (`-image`, `-image-preview`,
|
||||
# `nano-banana-pro-preview`) flow through the Nano Banana
|
||||
# `responseModalities` path in `_stream_gemini`. Retired 2.0
|
||||
# ids ARE NOT in this regex on purpose -- Google's ListModels
|
||||
# would otherwise re-surface them and they 404 on use.
|
||||
# `responseModalities` path in `_stream_gemini`. Retired 2.0 ids are
|
||||
# excluded on purpose -- ListModels would re-surface them and they
|
||||
# 404 on use.
|
||||
"model_id_allowlist": re.compile(
|
||||
r"^("
|
||||
r"gemini-3\.5-(?:flash|pro)(?:-preview)?|"
|
||||
|
|
@ -186,12 +181,12 @@ PROVIDER_REGISTRY: dict[str, dict[str, Any]] = {
|
|||
"base_url": "https://api.moonshot.ai/v1",
|
||||
# Current Kimi model lineup per the official docs:
|
||||
# https://platform.kimi.ai/docs/models
|
||||
# Listing/overview endpoints used to enumerate them:
|
||||
# Listing/overview endpoints:
|
||||
# https://platform.kimi.ai/docs/api/list-models
|
||||
# https://platform.kimi.ai/docs/api/overview
|
||||
# kimi-k2.6 and kimi-k2.5 are the two SoTA multimodal models we
|
||||
# surface in the picker; everything else (moonshot-v1-*, dated
|
||||
# k2 previews) is filtered out by model_id_allowlist below.
|
||||
# surface; everything else (moonshot-v1-*, dated k2 previews) is
|
||||
# filtered out by model_id_allowlist below.
|
||||
"default_models": [
|
||||
"kimi-k2.6",
|
||||
"kimi-k2.5",
|
||||
|
|
@ -205,8 +200,8 @@ PROVIDER_REGISTRY: dict[str, dict[str, Any]] = {
|
|||
"model_id_allowlist": re.compile(r"^kimi-k2\.[56]$"),
|
||||
# Both k2.6 and k2.5 are reasoning-class. The API rejects custom
|
||||
# sampling: "invalid temperature: only 1 is allowed for this model"
|
||||
# (and the same shape for top_p). Strip both fields from the
|
||||
# outbound body so the server falls back to its required defaults.
|
||||
# (same for top_p). Strip both fields from the outbound body so the
|
||||
# server falls back to its required defaults.
|
||||
"body_omit": ("temperature", "top_p"),
|
||||
},
|
||||
"qwen": {
|
||||
|
|
@ -228,9 +223,9 @@ PROVIDER_REGISTRY: dict[str, dict[str, Any]] = {
|
|||
"huggingface": {
|
||||
"display_name": "Hugging Face",
|
||||
"base_url": "https://router.huggingface.co/v1",
|
||||
# Seed the picker with a few popular ids so something is selectable
|
||||
# before the live /v1/models call resolves. The remote listing is
|
||||
# the source of truth — see model_list_mode below.
|
||||
# Seed the picker with popular ids so something is selectable before
|
||||
# the live /v1/models call resolves. The remote listing is the
|
||||
# source of truth -- see model_list_mode below.
|
||||
"default_models": [
|
||||
"openai/gpt-oss-120b",
|
||||
"deepseek-ai/DeepSeek-V3",
|
||||
|
|
@ -249,27 +244,26 @@ PROVIDER_REGISTRY: dict[str, dict[str, Any]] = {
|
|||
"https://huggingface.co/docs/inference-providers/index."
|
||||
),
|
||||
# /v1/models works on the HF router and returns the full chat-model
|
||||
# catalog (state.org/model[:policy] ids). Switch to remote so users
|
||||
# see live availability — the picker has a search box, and
|
||||
# loadModels() merges defaults so default_models entries remain
|
||||
# visible if the remote call fails.
|
||||
# catalog (state.org/model[:policy] ids). Remote so users see live
|
||||
# availability -- the picker has search, and loadModels() merges
|
||||
# defaults so default_models stay visible if the remote call fails.
|
||||
"model_list_mode": "remote",
|
||||
# Scope the catalog to first-party org repos we trust as primary
|
||||
# sources. The HF /v1/models response is otherwise hundreds of
|
||||
# ids long (community fine-tunes, mirrors, fp8 variants, etc.).
|
||||
# Scope to first-party org repos we trust as primary sources. The HF
|
||||
# /v1/models response is otherwise hundreds of ids long (community
|
||||
# fine-tunes, mirrors, fp8 variants, etc.).
|
||||
"model_id_allowlist": re.compile(
|
||||
r"^(openai|deepseek-ai|google|meta-llama|Qwen|moonshotai|mistralai|zai-org)/"
|
||||
),
|
||||
# Cap the post-filter list. /v1/models has no server-side limit
|
||||
# or popularity sort, so this is just "first N matches" — pair it
|
||||
# with the default_models seed so the most useful flagship ids
|
||||
# are always among the top regardless of the API's order.
|
||||
# Cap the post-filter list. /v1/models has no server-side limit or
|
||||
# popularity sort, so this is just "first N matches"; the
|
||||
# default_models seed keeps flagship ids near the top regardless of
|
||||
# the API's order.
|
||||
"model_id_limit": 15,
|
||||
},
|
||||
"vllm": {
|
||||
"display_name": "vLLM",
|
||||
# User-supplied via provider_base_url; the route layer already falls
|
||||
# back to the payload's base_url when the registry entry has none.
|
||||
# User-supplied via provider_base_url; the route layer falls back to
|
||||
# the payload's base_url when the registry entry has none.
|
||||
"base_url": "",
|
||||
"default_models": [],
|
||||
"supports_streaming": True,
|
||||
|
|
@ -277,15 +271,14 @@ PROVIDER_REGISTRY: dict[str, dict[str, Any]] = {
|
|||
"supports_tool_calling": True,
|
||||
"auth_header": "Authorization",
|
||||
"auth_prefix": "Bearer ",
|
||||
# Force /v1/chat/completions in stream_chat_completion — vLLM's
|
||||
# /v1/responses rebuilds messages and runs them through the loaded
|
||||
# model's chat template, which 400s on strict-alternation templates
|
||||
# (Gemma 3 raises "Conversation roles must alternate user/assistant
|
||||
# /user/assistant/..."). The chat-completions path takes messages
|
||||
# verbatim and avoids that template gauntlet.
|
||||
# Force /v1/chat/completions in stream_chat_completion -- vLLM's
|
||||
# /v1/responses rebuilds messages through the model's chat template,
|
||||
# which 400s on strict-alternation templates (Gemma 3 raises
|
||||
# "Conversation roles must alternate user/assistant/user/assistant
|
||||
# /..."). The chat-completions path takes messages verbatim.
|
||||
"notes": "Self-hosted vLLM server. Always routed to /v1/chat/completions.",
|
||||
# Surfaced through the frontend's CUSTOM_PROVIDER_PRESETS, not the
|
||||
# /api/providers/registry dropdown — see list_available_providers.
|
||||
# Surfaced via the frontend's CUSTOM_PROVIDER_PRESETS, not the
|
||||
# /api/providers/registry dropdown -- see list_available_providers.
|
||||
"hidden": True,
|
||||
},
|
||||
"ollama": {
|
||||
|
|
@ -371,10 +364,10 @@ def get_base_url(provider_type: str) -> str | None:
|
|||
def list_available_providers() -> list[dict[str, Any]]:
|
||||
"""Return all registered providers (for the /registry endpoint).
|
||||
|
||||
Hidden entries (``"hidden": True``) are filtered out — they exist in the
|
||||
registry only for backend lookups (e.g. ``supports_vision`` for vLLM) and
|
||||
are surfaced in the frontend via ``CUSTOM_PROVIDER_PRESETS`` instead of
|
||||
the cloud-provider dropdown.
|
||||
Hidden entries (``"hidden": True``) are filtered out: they exist only
|
||||
for backend lookups (e.g. ``supports_vision`` for vLLM) and are
|
||||
surfaced in the frontend via ``CUSTOM_PROVIDER_PRESETS`` instead of the
|
||||
cloud-provider dropdown.
|
||||
"""
|
||||
result = []
|
||||
for provider_type, info in PROVIDER_REGISTRY.items():
|
||||
|
|
|
|||
|
|
@ -4,17 +4,16 @@
|
|||
"""
|
||||
Safetensors/transformers agentic tool loop.
|
||||
|
||||
Wraps a single-turn cumulative-text generator (the existing
|
||||
``InferenceOrchestrator.generate_chat_response`` pipeline that streams
|
||||
from a worker subprocess) with the tool-calling, thinking-block,
|
||||
status, and metadata event protocol used by the GGUF path. Keeps the
|
||||
front-end SSE shape identical across backends so the chat UI does not
|
||||
care which engine actually ran the model.
|
||||
Wraps a single-turn cumulative-text generator (the
|
||||
``InferenceOrchestrator.generate_chat_response`` pipeline streaming from a
|
||||
worker subprocess) with the tool-calling, thinking-block, status, and
|
||||
metadata event protocol used by the GGUF path. The front-end SSE shape
|
||||
stays identical across backends, so the chat UI is engine-agnostic.
|
||||
|
||||
The GGUF path lives in ``llama_cpp.py`` and talks to llama-server's
|
||||
structured ``delta.tool_calls`` directly. Native transformers has no
|
||||
such structured channel, so this loop parses tool calls from the
|
||||
cumulative text and dispatches them via ``core.inference.tools``.
|
||||
The GGUF path (``llama_cpp.py``) uses llama-server's structured
|
||||
``delta.tool_calls`` directly. Native transformers has no such channel, so
|
||||
this loop parses tool calls from the cumulative text and dispatches them
|
||||
via ``core.inference.tools``.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
|
@ -41,7 +40,7 @@ from core.inference.tool_call_parser import (
|
|||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
# Buffer cap while waiting to disambiguate a possible tool-call prefix.
|
||||
# Buffer cap while disambiguating a possible tool-call prefix.
|
||||
_MAX_BUFFER_CHARS = 32
|
||||
|
||||
|
||||
|
|
@ -105,10 +104,10 @@ def _coerce_arguments(
|
|||
"""Normalise tool ``arguments`` to a dict.
|
||||
|
||||
Some templates emit a JSON string, others a bare query string. With
|
||||
``heal=True`` we accept a bare string as ``{<canonical_key>: ...}``
|
||||
so a Hermes-style call without proper JSON still runs the tool. The
|
||||
canonical key is picked per tool: ``code`` for python, ``command``
|
||||
for terminal, ``query`` for everything else (e.g. web_search).
|
||||
``heal=True`` a bare string becomes ``{<canonical_key>: ...}`` so a
|
||||
Hermes-style call without proper JSON still runs. Canonical key per
|
||||
tool: ``code`` for python, ``command`` for terminal, ``query`` otherwise
|
||||
(e.g. web_search).
|
||||
"""
|
||||
if isinstance(raw_args, dict):
|
||||
return raw_args
|
||||
|
|
@ -140,28 +139,23 @@ def run_safetensors_tool_loop(
|
|||
) -> Generator[dict, None, None]:
|
||||
"""Drive an agentic tool loop on top of a cumulative-text generator.
|
||||
|
||||
``single_turn(messages)`` must yield cumulative assistant text
|
||||
(each yield is a snapshot including all previously emitted tokens).
|
||||
The loop:
|
||||
``single_turn(messages)`` must yield cumulative assistant text (each
|
||||
yield is a snapshot of all tokens so far). The loop:
|
||||
|
||||
* Buffers the leading characters of every turn so it can decide
|
||||
whether the model is about to emit a tool call. Plain content
|
||||
starts streaming as soon as the buffer rules it out.
|
||||
* On detecting ``<tool_call>`` or ``<function=`` in the cumulative
|
||||
text, drains the rest of the turn silently and parses tool calls
|
||||
out of the full content.
|
||||
* Buffers each turn's leading chars to decide whether a tool call is
|
||||
coming. Plain content streams once the buffer rules it out.
|
||||
* On ``<tool_call>`` or ``<function=`` in the cumulative text, drains
|
||||
the rest of the turn silently and parses tool calls from the content.
|
||||
* Executes each tool via ``execute_tool``, appends the assistant
|
||||
tool-call message and the tool result to the conversation, and
|
||||
re-enters ``single_turn`` for the next iteration.
|
||||
* After ``max_tool_iterations`` turns without a final answer, asks
|
||||
the model once more to produce a final answer with no tools.
|
||||
tool-call message and tool result, and re-enters ``single_turn``.
|
||||
* After ``max_tool_iterations`` turns without a final answer, asks once
|
||||
more for a final answer with no tools.
|
||||
|
||||
Yields event dicts matching the GGUF path:
|
||||
|
||||
* ``{"type": "status", "text": ...}`` -- empty string clears the badge.
|
||||
* ``{"type": "content", "text": ...}`` -- cumulative cleaned text for
|
||||
the current assistant turn (the consumer should diff against its
|
||||
own ``prev_text`` cursor).
|
||||
the current turn (consumer diffs against its own ``prev_text`` cursor).
|
||||
* ``{"type": "tool_start", "tool_name", "tool_call_id", "arguments"}``
|
||||
* ``{"type": "tool_end", "tool_name", "tool_call_id", "result"}``
|
||||
"""
|
||||
|
|
@ -205,7 +199,7 @@ def run_safetensors_tool_loop(
|
|||
return
|
||||
|
||||
if not isinstance(cumulative, str):
|
||||
continue # defensive: pipeline only yields strings
|
||||
continue # defensive: pipeline yields only strings
|
||||
|
||||
delta = cumulative[len(prev_cumulative) :]
|
||||
prev_cumulative = cumulative
|
||||
|
|
@ -308,7 +302,7 @@ def run_safetensors_tool_loop(
|
|||
return
|
||||
|
||||
if detect_state == _state_buffering:
|
||||
# Buffer never resolved -- tool XML or plain content.
|
||||
# Buffer never resolved -- tool XML or plain content?
|
||||
stripped = content_buffer.lstrip()
|
||||
if stripped and has_tool_signal(stripped):
|
||||
detect_state = _state_draining
|
||||
|
|
@ -331,9 +325,9 @@ def run_safetensors_tool_loop(
|
|||
id_offset = next_call_id,
|
||||
)
|
||||
if not safety_tc:
|
||||
# Final answer: streaming already emitted content.
|
||||
# Skip a final=True re-strip so literal "<tool_call>"
|
||||
# in prose survives when no real tool call parsed.
|
||||
# Final answer: streaming already emitted content. Skip the
|
||||
# final=True re-strip so literal "<tool_call>" in prose
|
||||
# survives when no real tool call parsed.
|
||||
yield {"type": "status", "text": ""}
|
||||
return
|
||||
tool_calls = safety_tc
|
||||
|
|
@ -349,8 +343,8 @@ def run_safetensors_tool_loop(
|
|||
id_offset = next_call_id,
|
||||
)
|
||||
if not tool_calls and auto_heal_tool_calls:
|
||||
# Parser found nothing -- surface raw content so any
|
||||
# literal "<tool_call>" prose is preserved.
|
||||
# Parser found nothing -- surface raw content so literal
|
||||
# "<tool_call>" prose is preserved.
|
||||
if content_accum:
|
||||
yield {"type": "content", "text": content_accum}
|
||||
if provisional_render_html_started:
|
||||
|
|
@ -436,9 +430,8 @@ def run_safetensors_tool_loop(
|
|||
render_html_succeeded = True
|
||||
tool_call_history.append((tc_key, is_error))
|
||||
|
||||
# Strip frontend image sentinel from the model's view.
|
||||
# Cut at the first occurrence so leading and consecutive
|
||||
# sentinels are both removed.
|
||||
# Strip frontend image sentinel from the model's view. Cut at the
|
||||
# first occurrence so leading and consecutive sentinels both go.
|
||||
result_for_model = result
|
||||
if isinstance(result_for_model, str) and "__IMAGES__:" in result_for_model:
|
||||
result_for_model = result_for_model.split("__IMAGES__:", 1)[0].rstrip()
|
||||
|
|
|
|||
|
|
@ -13,9 +13,9 @@ import re
|
|||
|
||||
# _TOOL_CLOSED_PATS: closed pairs only. _TOOL_ALL_PATS: also trailing
|
||||
# unclosed runs so truncated tails don't leak markup.
|
||||
# Function-name char set tracks OpenAI's ^[a-zA-Z0-9_-]{1,64}$ so MCP
|
||||
# tool names that contain a hyphen (e.g. mcp__srv__list-issues) parse
|
||||
# the same as the built-in web_search/python/terminal names.
|
||||
# Function-name char set tracks OpenAI's ^[a-zA-Z0-9_-]{1,64}$ so hyphenated
|
||||
# MCP tool names (e.g. mcp__srv__list-issues) parse the same as built-in
|
||||
# web_search/python/terminal names.
|
||||
_TOOL_CLOSED_PATS = [
|
||||
re.compile(r"<tool_call>.*?</tool_call>", re.DOTALL),
|
||||
re.compile(r"<function=[\w-]+>.*?</function>", re.DOTALL),
|
||||
|
|
@ -72,8 +72,8 @@ _TC_JSON_START_RE = re.compile(r"<tool_call>\s*\{")
|
|||
_TC_FUNC_START_RE = re.compile(r"<function=([\w-]+)>\s*")
|
||||
_TC_END_TAG_RE = re.compile(r"</tool_call>")
|
||||
_TC_FUNC_CLOSE_RE = re.compile(r"\s*</function>\s*$")
|
||||
# Parameter names can carry hyphens too (e.g. MCP tool schemas with
|
||||
# `issue-number`, `repo-name`); using `\w+` here dropped those keys.
|
||||
# Parameter names can carry hyphens too (e.g. MCP schemas with
|
||||
# `issue-number`, `repo-name`); `\w+` alone dropped those keys.
|
||||
_TC_PARAM_START_RE = re.compile(r"<parameter=([\w-]+)>\s*")
|
||||
_TC_PARAM_CLOSE_RE = re.compile(r"\s*</parameter>\s*$")
|
||||
_PARAM_CLOSE_TAG = "</parameter>"
|
||||
|
|
@ -124,8 +124,8 @@ def parse_tool_calls_from_text(content: str, *, id_offset: int = 0) -> list[dict
|
|||
"""
|
||||
tool_calls: list[dict] = []
|
||||
|
||||
# Pattern 1: <tool_call>{json}. Balanced-brace scan that skips
|
||||
# braces inside JSON strings.
|
||||
# Pattern 1: <tool_call>{json}. Balanced-brace scan skipping braces
|
||||
# inside JSON strings.
|
||||
for m in _TC_JSON_START_RE.finditer(content):
|
||||
brace_start = m.end() - 1 # position of the opening {
|
||||
depth, i = 0, brace_start
|
||||
|
|
@ -165,9 +165,9 @@ def parse_tool_calls_from_text(content: str, *, id_offset: int = 0) -> list[dict
|
|||
except (json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
|
||||
# Pattern 2: <function=name><parameter=k>v... -- closing tags
|
||||
# optional; don't use </function> as body boundary because code
|
||||
# values can contain that literal.
|
||||
# Pattern 2: <function=name><parameter=k>v... -- closing tags optional;
|
||||
# don't use </function> as body boundary since code values can contain
|
||||
# that literal.
|
||||
if not tool_calls:
|
||||
func_starts = [
|
||||
fm
|
||||
|
|
@ -190,8 +190,8 @@ def parse_tool_calls_from_text(content: str, *, id_offset: int = 0) -> list[dict
|
|||
arguments: dict = {}
|
||||
param_starts = list(_TC_PARAM_START_RE.finditer(body))
|
||||
if len(param_starts) == 1:
|
||||
# Single param: take everything to body end so
|
||||
# embedded </parameter> in code strings is preserved.
|
||||
# Single param: take everything to body end so embedded
|
||||
# </parameter> in code strings is preserved.
|
||||
pm = param_starts[0]
|
||||
val = body[pm.end() :]
|
||||
val = _TC_PARAM_CLOSE_RE.sub("", val)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"""
|
||||
Tool definitions and executors for LLM tool calling.
|
||||
|
||||
Supports web search (DuckDuckGo), Python code execution, and terminal commands.
|
||||
Web search (DuckDuckGo), Python code execution, and terminal commands.
|
||||
"""
|
||||
|
||||
import ast
|
||||
|
|
@ -42,9 +42,8 @@ logger = get_logger(__name__)
|
|||
|
||||
_EXEC_TIMEOUT = 300 # 5 minutes
|
||||
|
||||
# Pre-import modules used in _sandbox_preexec at module level so that
|
||||
# the preexec_fn closure does not trigger the import machinery in the
|
||||
# forked child (which can deadlock in multi-threaded servers).
|
||||
# Pre-import _sandbox_preexec modules at module level so the preexec_fn closure
|
||||
# doesn't trigger imports in the forked child (can deadlock multi-threaded servers).
|
||||
_libc = None
|
||||
if sys.platform == "linux":
|
||||
try:
|
||||
|
|
@ -64,8 +63,8 @@ if sys.platform != "win32":
|
|||
except ImportError:
|
||||
pass
|
||||
|
||||
# Strict raster-image allowlist for sandbox file serving.
|
||||
# No .svg (XSS risk via embedded scripts), no .html, no .pdf.
|
||||
# Raster-image allowlist for sandbox file serving.
|
||||
# No .svg (XSS via embedded scripts), no .html, no .pdf.
|
||||
_IMAGE_EXTS = frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"})
|
||||
_MAX_OUTPUT_CHARS = 8000 # truncate long output
|
||||
_BLOCKED_COMMANDS_COMMON = frozenset(
|
||||
|
|
@ -122,9 +121,9 @@ _BLOCKED_COMMANDS = (
|
|||
|
||||
|
||||
_SHELL_SEPARATORS = frozenset({";", "&&", "||", "|", "&", "\n", "(", ")", "`", "{", "}"})
|
||||
# Bash keywords that introduce a new command position (then $cmd, do $cmd, etc.).
|
||||
# Bash keywords starting a new command position (then $cmd, do $cmd, etc.).
|
||||
_SHELL_KEYWORDS_AS_SEP = frozenset({"then", "do", "else", "elif"})
|
||||
# Wrappers whose next non-flag argument is itself the command Bash will exec.
|
||||
# Wrappers whose next non-flag argument is the command Bash will exec.
|
||||
_COMMAND_PREFIXES = frozenset(
|
||||
{
|
||||
"env",
|
||||
|
|
@ -152,21 +151,19 @@ _FIND_EXEC_FLAGS = frozenset({"-exec", "-execdir", "-ok", "-okdir"})
|
|||
def _find_blocked_commands(command: str) -> set[str]:
|
||||
"""Detect blocked commands at shell command position only.
|
||||
|
||||
A token is at command position if it is the first token, or if the
|
||||
preceding token is a shell separator / brace-group opener / keyword
|
||||
that starts a new command (`then`, `do`, etc.), or a command-prefix
|
||||
wrapper like `env` / `time` / `xargs` (the next token is the real
|
||||
command). Tokens in argument position (`grep -r curl .`,
|
||||
`echo source the data`, `ls /usr/bin/curl`) are passed through.
|
||||
Also scans `find ... -exec CMD` and recurses into bash -c / cmd /c.
|
||||
A token is at command position if it is the first token, or follows a
|
||||
shell separator / brace-group opener / new-command keyword (`then`, `do`,
|
||||
etc.), or a command-prefix wrapper like `env` / `time` / `xargs` (next
|
||||
token is the real command). Tokens in argument position (`grep -r curl .`,
|
||||
`echo source the data`, `ls /usr/bin/curl`) pass through. Also scans
|
||||
`find ... -exec CMD` and recurses into bash -c / cmd /c.
|
||||
"""
|
||||
blocked: set[str] = set()
|
||||
|
||||
# shlex with punctuation_chars splits `;`, `&&`, `||`, `|`, `(`, `)`, `` ` ``
|
||||
# off as their own tokens so we can detect command position even when a
|
||||
# caller writes `echo done; rm -rf x` (no whitespace) or quote-splits the
|
||||
# command name itself (`r''m` collapses to a single token `rm` at command
|
||||
# position after the `;` separator).
|
||||
# into their own tokens, so we detect command position even for
|
||||
# `echo done; rm -rf x` (no whitespace) or quote-split command names
|
||||
# (`r''m` collapses to `rm` at command position after `;`).
|
||||
try:
|
||||
if sys.platform == "win32":
|
||||
tokens = shlex.split(command, posix = False)
|
||||
|
|
@ -178,9 +175,8 @@ def _find_blocked_commands(command: str) -> set[str]:
|
|||
tokens = command.split()
|
||||
|
||||
def _token_basename(tok: str) -> str:
|
||||
# shlex may glue trailing meta-chars onto a token (`rm;`); strip them
|
||||
# so the basename match still hits `rm`. Leading shell-state chars
|
||||
# likewise.
|
||||
# shlex may glue meta-chars onto a token (`rm;`); strip leading and
|
||||
# trailing ones so the basename still matches `rm`.
|
||||
tok = tok.strip(";&|()`{}")
|
||||
base = os.path.basename(tok).lower()
|
||||
stem, ext = os.path.splitext(base)
|
||||
|
|
@ -189,33 +185,32 @@ def _find_blocked_commands(command: str) -> set[str]:
|
|||
return base
|
||||
|
||||
expect_command = True # start of string is a command position
|
||||
prefix_pending = False # last command-position token was env/time/timeout/xargs/...
|
||||
prefix_pending = False # last cmd-position token was a wrapper (env/time/xargs/...)
|
||||
for token in tokens:
|
||||
if token in _SHELL_SEPARATORS or token in _SHELL_KEYWORDS_AS_SEP:
|
||||
expect_command = True
|
||||
prefix_pending = False
|
||||
continue
|
||||
if token.startswith("-"):
|
||||
# Flags belong to the active command. While a wrapper prefix is
|
||||
# waiting for its command (`stdbuf -oL cmd`, `xargs -- cmd`),
|
||||
# keep expect_command intact.
|
||||
# Flags belong to the active command. While a wrapper prefix awaits
|
||||
# its command (`stdbuf -oL cmd`, `xargs -- cmd`), keep expect_command.
|
||||
if not prefix_pending:
|
||||
expect_command = False
|
||||
continue
|
||||
if not expect_command:
|
||||
continue
|
||||
# FOO=bar prefix: assignment list, next non-assignment token is the command.
|
||||
# FOO=bar assignment prefix; next non-assignment token is the command.
|
||||
if _ASSIGNMENT_RE.match(token):
|
||||
continue
|
||||
# `timeout 1 cmd` / `nice -n 5 cmd` style numeric wrapper arg.
|
||||
# Numeric wrapper arg: `timeout 1 cmd` / `nice -n 5 cmd`.
|
||||
if prefix_pending and token.lstrip("-").isdigit():
|
||||
continue
|
||||
base = _token_basename(token)
|
||||
if base in _BLOCKED_COMMANDS:
|
||||
blocked.add(base)
|
||||
# Wrappers (`env` / `time` / `xargs` / `sudo`) consume one command; the
|
||||
# next non-flag, non-numeric token is the real command. `sudo` is
|
||||
# already in _BLOCKED_COMMANDS, so it's flagged AND we keep walking.
|
||||
# next non-flag, non-numeric token is the real command. `sudo` is in
|
||||
# _BLOCKED_COMMANDS, so it's flagged AND we keep walking.
|
||||
if base in _COMMAND_PREFIXES:
|
||||
prefix_pending = True
|
||||
continue
|
||||
|
|
@ -229,10 +224,10 @@ def _find_blocked_commands(command: str) -> set[str]:
|
|||
if base in _BLOCKED_COMMANDS:
|
||||
blocked.add(base)
|
||||
|
||||
# Regex: blocked words at shell command boundaries that shlex won't see,
|
||||
# e.g. inside an unquoted $(rm -rf), <(rm), backtick chain, or appended to
|
||||
# a separator with no whitespace ("foo;rm"). Anchored to command-position
|
||||
# delimiters; does not match in argument position.
|
||||
# Regex: blocked words at command boundaries shlex won't see, e.g. inside
|
||||
# unquoted $(rm -rf), <(rm), backtick chains, or after a separator with no
|
||||
# whitespace ("foo;rm"). Anchored to command-position delimiters; no match
|
||||
# in argument position.
|
||||
lowered = command.lower()
|
||||
if _BLOCKED_COMMANDS:
|
||||
words_alt = "|".join(re.escape(w) for w in sorted(_BLOCKED_COMMANDS))
|
||||
|
|
@ -243,11 +238,10 @@ def _find_blocked_commands(command: str) -> set[str]:
|
|||
)
|
||||
blocked.update(re.findall(pattern, lowered))
|
||||
|
||||
# Nested shell invocations (bash -c 'sudo whoami',
|
||||
# bash -lc '...', bash --login -c '...', cmd /c '...').
|
||||
# When a -c or /c flag is found, look backwards for a shell name
|
||||
# (skipping intermediate flags like --login, -l, -x) and recursively
|
||||
# scan the nested command string.
|
||||
# Nested shell invocations (bash -c 'sudo whoami', bash -lc '...',
|
||||
# bash --login -c '...', cmd /c '...'). On a -c or /c flag, look
|
||||
# backwards for a shell name (skipping flags like --login, -l, -x)
|
||||
# and recursively scan the nested command string.
|
||||
_SHELLS = {"bash", "sh", "zsh", "dash", "ksh", "csh", "tcsh", "fish"}
|
||||
_SHELLS_WIN = {"cmd", "cmd.exe"}
|
||||
for i, token in enumerate(tokens):
|
||||
|
|
@ -259,10 +253,9 @@ def _find_blocked_commands(command: str) -> set[str]:
|
|||
is_win_c = tok_lower == "/c"
|
||||
if not (is_unix_c or is_win_c) or i < 1 or i + 1 >= len(tokens):
|
||||
continue
|
||||
# Look backwards past any flags to find the shell binary.
|
||||
# On Unix, flags start with - (skip those). On Windows, flags
|
||||
# start with / but so do absolute paths, so only skip short
|
||||
# single-char /X flags (not /bin/bash style paths).
|
||||
# Look backwards past flags to find the shell binary. Unix flags
|
||||
# start with - (skip). Windows flags start with / but so do absolute
|
||||
# paths, so only skip short single-char /X flags (not /bin/bash).
|
||||
for j in range(i - 1, -1, -1):
|
||||
prev = tokens[j]
|
||||
if prev.startswith("-"):
|
||||
|
|
@ -282,17 +275,15 @@ def _find_blocked_commands(command: str) -> set[str]:
|
|||
def _build_safe_env(workdir: str) -> dict[str, str]:
|
||||
"""Build a minimal, credential-free environment for sandboxed subprocesses.
|
||||
|
||||
Whitelist-built from scratch -- the parent process env is NOT inherited.
|
||||
Only PATH / HOME / TMPDIR / LANG / TERM / PYTHONIOENCODING (+ VIRTUAL_ENV
|
||||
or Windows SystemRoot when applicable) reach the child. HF_TOKEN,
|
||||
WANDB_API_KEY, AWS_*, GH_TOKEN, OPENAI_API_KEY, LD_PRELOAD, DYLD_*, and
|
||||
every other parent var are absent by construction. HOME points at the
|
||||
sandbox workdir so HF / wandb / aws SDKs cannot read cached credentials
|
||||
from the operator's real ~/.
|
||||
Whitelist-built from scratch; parent env is NOT inherited. Only PATH /
|
||||
HOME / TMPDIR / LANG / TERM / PYTHONIOENCODING (+ VIRTUAL_ENV or Windows
|
||||
SystemRoot when applicable) reach the child. HF_TOKEN, WANDB_API_KEY,
|
||||
AWS_*, GH_TOKEN, OPENAI_API_KEY, LD_PRELOAD, DYLD_*, and all other parent
|
||||
vars are absent. HOME points at the sandbox workdir so HF / wandb / aws
|
||||
SDKs can't read cached credentials from the operator's real ~/.
|
||||
"""
|
||||
# Start with the directory containing the running Python interpreter
|
||||
# so that subprocess calls to 'python', 'pip', etc. resolve to the
|
||||
# same environment the Studio server is running in.
|
||||
# Start with the running interpreter's directory so 'python', 'pip', etc.
|
||||
# resolve to the same environment the Studio server runs in.
|
||||
exe_dir = os.path.dirname(sys.executable)
|
||||
path_entries = [exe_dir] if exe_dir else []
|
||||
|
||||
|
|
@ -354,9 +345,9 @@ def _sandbox_preexec():
|
|||
except (OSError, AttributeError):
|
||||
pass
|
||||
|
||||
# CLONE_NEWNET intentionally not applied: where userns is enabled it
|
||||
# blocks all egress, including allowlisted hosts. Network policy is
|
||||
# enforced by the AST host check and the bash blocklist.
|
||||
# CLONE_NEWNET not applied: with userns enabled it blocks all egress,
|
||||
# including allowlisted hosts. Network policy is enforced by the AST
|
||||
# host check and the bash blocklist.
|
||||
|
||||
if _resource is not None:
|
||||
# RLIMIT_NPROC is per-real-UID, so the cap is well above normal usage.
|
||||
|
|
@ -381,10 +372,9 @@ def _sandbox_preexec():
|
|||
pass
|
||||
try:
|
||||
# Default high enough for multi-shard safetensors mmaps + Python's
|
||||
# own handle count; tunable via env for installs that hit the cap.
|
||||
# Clamp to the inherited hard limit so setrlimit doesn't ValueError
|
||||
# on machines where the parent's hard cap is below the requested
|
||||
# value (would otherwise leave NOFILE at the parent's default).
|
||||
# handle count; tunable via env. Clamp to the inherited hard limit
|
||||
# so setrlimit doesn't ValueError where the parent's hard cap is
|
||||
# below the request (else NOFILE stays at the parent's default).
|
||||
nofile = int(os.environ.get("UNSLOTH_STUDIO_SANDBOX_NOFILE", "16384"))
|
||||
_soft_cur, hard_cur = _resource.getrlimit(_resource.RLIMIT_NOFILE)
|
||||
target = nofile if hard_cur == _resource.RLIM_INFINITY else min(nofile, hard_cur)
|
||||
|
|
@ -401,8 +391,7 @@ def _get_shell_cmd(command: str) -> list[str]:
|
|||
|
||||
|
||||
# Per-session working directories so each chat thread gets its own sandbox.
|
||||
# Falls back to a shared ~/studio_sandbox/_default for API callers without a
|
||||
# session_id.
|
||||
# Falls back to ~/studio_sandbox/_default for callers without a session_id.
|
||||
_workdirs: dict[str, str] = {}
|
||||
|
||||
|
||||
|
|
@ -681,10 +670,10 @@ def execute_tool(
|
|||
timeout: int | None = _TIMEOUT_UNSET,
|
||||
session_id: str | None = None,
|
||||
) -> str:
|
||||
"""Execute a tool by name with the given arguments. Returns result as a string.
|
||||
"""Execute a tool by name with the given arguments. Returns a string.
|
||||
|
||||
``timeout``: int sets per-call limit in seconds, ``None`` means no limit,
|
||||
unset (default) uses ``_EXEC_TIMEOUT`` (300 s).
|
||||
``timeout``: int = per-call limit in seconds, ``None`` = no limit,
|
||||
unset (default) = ``_EXEC_TIMEOUT`` (300 s).
|
||||
``session_id``: optional thread/session ID for per-conversation sandbox isolation.
|
||||
"""
|
||||
logger.info(f"execute_tool: name={name}, session_id={session_id}, timeout={timeout}")
|
||||
|
|
@ -726,10 +715,10 @@ def execute_tool(
|
|||
|
||||
|
||||
_MAX_PAGE_CHARS = 16000 # limit fetched page text (after HTML-to-MD conversion)
|
||||
# Raw download cap. Must be larger than _MAX_PAGE_CHARS because SSR pages
|
||||
# embed large <head> sections (CSS, JS, SVGs) that are stripped during
|
||||
# HTML-to-Markdown conversion. 512 KB is enough to reach article content
|
||||
# on GitBook / Next.js / Docusaurus pages whose <head> alone can be 200 KB.
|
||||
# Raw download cap. Larger than _MAX_PAGE_CHARS because SSR pages embed large
|
||||
# <head> sections (CSS, JS, SVGs) stripped during HTML-to-Markdown conversion.
|
||||
# 512 KB reaches article content on GitBook / Next.js / Docusaurus pages whose
|
||||
# <head> alone can be 200 KB.
|
||||
_MAX_FETCH_BYTES = 512 * 1024
|
||||
|
||||
_USER_AGENTS = (
|
||||
|
|
@ -750,14 +739,13 @@ class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|||
|
||||
|
||||
class _PinnedHTTPSConnection(http.client.HTTPSConnection):
|
||||
"""HTTPS connection that connects to a pinned IP but uses a different
|
||||
hostname for SNI and certificate verification.
|
||||
"""HTTPS connection to a pinned IP, using a different hostname for SNI
|
||||
and certificate verification.
|
||||
|
||||
The SSRF IP-pinning rewrites URLs to raw IPs. A normal HTTPSConnection
|
||||
would then send no SNI and verify the cert against the IP, both of which
|
||||
fail. This subclass splits the two concerns: TCP connects to the pinned
|
||||
IP (``host`` parameter) while TLS uses ``sni_hostname`` for the
|
||||
ClientHello and cert check.
|
||||
SSRF IP-pinning rewrites URLs to raw IPs. A normal HTTPSConnection would
|
||||
then send no SNI and verify the cert against the IP, both of which fail.
|
||||
This subclass splits the concerns: TCP connects to the pinned IP (``host``)
|
||||
while TLS uses ``sni_hostname`` for the ClientHello and cert check.
|
||||
"""
|
||||
|
||||
def __init__(self, host: str, *, sni_hostname: str, **kwargs):
|
||||
|
|
@ -765,8 +753,8 @@ class _PinnedHTTPSConnection(http.client.HTTPSConnection):
|
|||
self._sni_hostname = sni_hostname
|
||||
|
||||
def connect(self):
|
||||
# TCP connect to the pinned IP stored in self.host (+ tunnel if
|
||||
# a proxy is configured via set_tunnel, though we do not use one).
|
||||
# TCP connect to the pinned IP in self.host (+ tunnel if a proxy is set
|
||||
# via set_tunnel, though we don't use one).
|
||||
http.client.HTTPConnection.connect(self)
|
||||
# TLS handshake with the real hostname for SNI + cert verification.
|
||||
self.sock = self._context.wrap_socket(
|
||||
|
|
@ -778,8 +766,8 @@ class _PinnedHTTPSConnection(http.client.HTTPSConnection):
|
|||
class _SNIHTTPSHandler(urllib.request.HTTPSHandler):
|
||||
"""HTTPS handler that sends the correct SNI hostname during TLS handshake.
|
||||
|
||||
The SSRF IP-pinning rewrites URLs to raw IPs, which breaks SNI and cert
|
||||
verification. This handler returns a ``_PinnedHTTPSConnection`` that
|
||||
SSRF IP-pinning rewrites URLs to raw IPs, breaking SNI and cert
|
||||
verification. This handler returns a ``_PinnedHTTPSConnection`` that
|
||||
connects to the pinned IP but verifies TLS against the original hostname.
|
||||
"""
|
||||
|
||||
|
|
@ -798,9 +786,9 @@ class _SNIHTTPSHandler(urllib.request.HTTPSHandler):
|
|||
def _validate_and_resolve_host(hostname: str, port: int) -> tuple[bool, str, str]:
|
||||
"""Resolve *hostname*, reject non-public IPs, return a pinned IP string.
|
||||
|
||||
Returns ``(ok, reason_or_empty, resolved_ip)``. The caller should
|
||||
connect to *resolved_ip* (with a ``Host`` header) to prevent DNS
|
||||
rebinding between validation and the actual fetch.
|
||||
Returns ``(ok, reason_or_empty, resolved_ip)``. The caller should connect
|
||||
to *resolved_ip* (with a ``Host`` header) to prevent DNS rebinding between
|
||||
validation and the actual fetch.
|
||||
"""
|
||||
import ipaddress
|
||||
import socket
|
||||
|
|
@ -815,14 +803,14 @@ def _validate_and_resolve_host(hostname: str, port: int) -> tuple[bool, str, str
|
|||
|
||||
for *_, sockaddr in infos:
|
||||
ip = ipaddress.ip_address(sockaddr[0])
|
||||
# `not ip.is_global` rejects every category the denylist below
|
||||
# also rejects PLUS shared address space (100.64.0.0/10 carrier-
|
||||
# grade NAT) and benchmarking/documentation/exchange ranges that
|
||||
# Python classifies with `is_private=False` and `is_global=False`
|
||||
# `not ip.is_global` rejects every category the denylist below rejects
|
||||
# PLUS shared address space (100.64.0.0/10 carrier-grade NAT) and
|
||||
# benchmarking/documentation/exchange ranges Python marks
|
||||
# `is_private=False` and `is_global=False`
|
||||
# (see https://docs.python.org/3/library/ipaddress.html#ipaddress.IPv4Address.is_global).
|
||||
# The explicit predicates after it give human-readable categories
|
||||
# in the error message, but a single non-global check is the
|
||||
# source of truth and prevents future ranges from leaking.
|
||||
# The explicit predicates after it give human-readable categories in
|
||||
# the error message; the non-global check is the source of truth and
|
||||
# prevents future ranges from leaking.
|
||||
if (
|
||||
not ip.is_global
|
||||
or ip.is_private
|
||||
|
|
@ -872,8 +860,8 @@ def _fetch_page_text(
|
|||
ua = random.choice(_USER_AGENTS)
|
||||
|
||||
for _hop in range(5):
|
||||
# Pin to the validated IP to prevent DNS rebinding.
|
||||
# Rewrite the URL to use the IP and set the Host header.
|
||||
# Pin to the validated IP (prevents DNS rebinding): rewrite the URL
|
||||
# to the IP and set the Host header.
|
||||
cp = urlparse(current_url)
|
||||
# Bracket IPv6 addresses so the netloc is valid in a URL.
|
||||
ip_str = f"[{pinned_ip}]" if ":" in pinned_ip else pinned_ip
|
||||
|
|
@ -926,7 +914,7 @@ def _fetch_page_text(
|
|||
except Exception as e:
|
||||
return f"Failed to fetch URL: {e}"
|
||||
|
||||
# Convert HTML to Markdown using the builtin converter (no external deps)
|
||||
# Convert HTML to Markdown with the builtin converter (no external deps)
|
||||
from ._html_to_md import html_to_markdown
|
||||
|
||||
text = html_to_markdown(raw_html)
|
||||
|
|
@ -981,7 +969,7 @@ def _web_search(
|
|||
|
||||
def _check_signal_escape_patterns(code: str):
|
||||
"""
|
||||
Check if code contains patterns that could escape signal-based timeouts.
|
||||
Check for patterns that could escape signal-based timeouts.
|
||||
|
||||
Vendored from unsloth_zoo.rl_environments to avoid importing unsloth_zoo
|
||||
(which requires GPU drivers and fails on Mac/Apple Silicon).
|
||||
|
|
@ -1071,8 +1059,8 @@ def _check_signal_escape_patterns(code: str):
|
|||
return parts
|
||||
return []
|
||||
|
||||
# Keyword argument names that carry command content (as opposed to
|
||||
# control flags like check=True, text=True, capture_output=True).
|
||||
# Kwarg names that carry command content (not control flags like
|
||||
# check=True, text=True, capture_output=True).
|
||||
_CMD_KWARGS = frozenset({"args", "command", "executable", "path", "file"})
|
||||
|
||||
def _check_args_for_blocked(args_nodes):
|
||||
|
|
@ -1093,8 +1081,8 @@ def _check_signal_escape_patterns(code: str):
|
|||
self.signal_aliases = {"signal"}
|
||||
self.os_aliases = {"os"}
|
||||
self.subprocess_aliases = {"subprocess"}
|
||||
# Maps bare function names to their fully-qualified form
|
||||
# for from-import tracking (e.g. "system" -> "os.system")
|
||||
# Maps bare function names to fully-qualified form for
|
||||
# from-import tracking (e.g. "system" -> "os.system")
|
||||
self.shell_exec_aliases: dict[str, str] = {}
|
||||
self.loop_depth = 0
|
||||
|
||||
|
|
@ -1197,7 +1185,7 @@ def _check_signal_escape_patterns(code: str):
|
|||
)
|
||||
|
||||
# --- Shell escape detection ---
|
||||
# Resolve the fully qualified function name for os.*/subprocess.*
|
||||
# Resolve the FQ function name for os.*/subprocess.*
|
||||
shell_func = None
|
||||
if isinstance(func, ast.Attribute):
|
||||
if isinstance(func.value, ast.Name):
|
||||
|
|
@ -1229,7 +1217,7 @@ def _check_signal_escape_patterns(code: str):
|
|||
blocked_in_args = _check_args_for_blocked(all_call_args)
|
||||
|
||||
if has_opaque_kwargs:
|
||||
# Can't inspect dynamic **kwargs -- flag as unsafe
|
||||
# Can't inspect dynamic **kwargs; flag as unsafe
|
||||
shell_escapes.append(
|
||||
{
|
||||
"type": "shell_escape_dynamic",
|
||||
|
|
@ -1251,8 +1239,8 @@ def _check_signal_escape_patterns(code: str):
|
|||
else:
|
||||
# Only flag dynamic args for functions that interpret
|
||||
# strings as shell commands, or when shell= might be
|
||||
# enabled. Treat any non-literal-False shell= value
|
||||
# as potentially True (conservative).
|
||||
# enabled. Treat any non-literal-False shell= value as
|
||||
# potentially True (conservative).
|
||||
_STRING_SHELL_FUNCS = frozenset(
|
||||
{
|
||||
"os.system",
|
||||
|
|
@ -1268,7 +1256,7 @@ def _check_signal_escape_patterns(code: str):
|
|||
shell_safe = shell_node is None or (
|
||||
isinstance(shell_node, ast.Constant) and shell_node.value is False
|
||||
)
|
||||
# Dynamic shell-exec args (chr/format/concat bypasses).
|
||||
# Dynamic shell-exec args (chr/format/concat bypasses)
|
||||
if (
|
||||
shell_func in _STRING_SHELL_FUNCS
|
||||
or shell_func in _SHELL_EXEC_FUNCS
|
||||
|
|
@ -1311,10 +1299,9 @@ def _check_signal_escape_patterns(code: str):
|
|||
)
|
||||
elif isinstance(node.type, ast.Name):
|
||||
# Only flag BaseException and TimeoutError, NOT Exception.
|
||||
# except Exception does not catch SystemExit or
|
||||
# KeyboardInterrupt, so it cannot suppress timeout
|
||||
# enforcement. Flagging Exception causes false positives
|
||||
# on normal error-handling patterns.
|
||||
# except Exception doesn't catch SystemExit/KeyboardInterrupt,
|
||||
# so it can't suppress timeout enforcement; flagging it would
|
||||
# false-positive on normal error handling.
|
||||
if node.type.id in ("TimeoutError", "BaseException"):
|
||||
exception_catching.append(
|
||||
{
|
||||
|
|
@ -1342,9 +1329,9 @@ def _check_signal_escape_patterns(code: str):
|
|||
if visitor.imports_signal and not signal_tampering:
|
||||
warnings.append("Code imports 'signal' module - review manually for safety")
|
||||
|
||||
# Static host policy: block metadata hosts and any literal host outside
|
||||
# the trusted allowlist; uploads blocked regardless of host. Dynamic hosts
|
||||
# are caught by the bash blocklist instead.
|
||||
# Static host policy: block metadata hosts and any literal host outside the
|
||||
# trusted allowlist; uploads blocked regardless of host. Dynamic hosts are
|
||||
# caught by the bash blocklist instead.
|
||||
network_calls: list[dict] = []
|
||||
sensitive_file_reads: list[dict] = []
|
||||
_NETWORK_FQ_PREFIXES = (
|
||||
|
|
@ -1591,9 +1578,9 @@ def _check_signal_escape_patterns(code: str):
|
|||
return False
|
||||
|
||||
# Bare method-name fallback (`x.upload_file(...)`) is intentionally fuzzy,
|
||||
# but should only fire when huggingface_hub / hf_api is actually imported
|
||||
# somewhere in the snippet -- otherwise paramiko.upload_file, boto3
|
||||
# create_commit, etc. hit a false positive. We pre-scan for the imports.
|
||||
# but should only fire when huggingface_hub / hf_api is imported in the
|
||||
# snippet; else paramiko.upload_file, boto3 create_commit, etc. would
|
||||
# false-positive. We pre-scan for the imports.
|
||||
_HF_IMPORT_MODULES = (
|
||||
"huggingface_hub",
|
||||
"hf_api",
|
||||
|
|
@ -1611,8 +1598,8 @@ def _check_signal_escape_patterns(code: str):
|
|||
if root in _HF_IMPORT_MODULES:
|
||||
return True
|
||||
elif isinstance(n, ast.Call) and n.args:
|
||||
# __import__('huggingface_hub'), importlib.import_module('huggingface_hub'),
|
||||
# and bare import_module('huggingface_hub') (via `from importlib import ...`).
|
||||
# __import__('huggingface_hub'), importlib.import_module(...),
|
||||
# and bare import_module(...) (via `from importlib import ...`).
|
||||
arg0 = n.args[0]
|
||||
if not (isinstance(arg0, ast.Constant) and isinstance(arg0.value, str)):
|
||||
continue
|
||||
|
|
@ -1635,8 +1622,8 @@ def _check_signal_escape_patterns(code: str):
|
|||
|
||||
Catches `HfApi().upload_file(...)` (Attribute) and
|
||||
`from huggingface_hub import upload_file; upload_file(...)` (Name).
|
||||
The bare-name branch fires only when an HF import is in scope, mirroring
|
||||
the Attribute branch's gating so paramiko/boto3 do not false-positive.
|
||||
The bare-name branch fires only when an HF import is in scope, so
|
||||
paramiko/boto3 don't false-positive.
|
||||
"""
|
||||
if not _hf_in_scope:
|
||||
return None
|
||||
|
|
@ -1649,7 +1636,7 @@ def _check_signal_escape_patterns(code: str):
|
|||
|
||||
# Kwargs that ship a credential over the wire. Sandbox env strips HF_TOKEN
|
||||
# / WANDB_API_KEY / AWS_* up front, so any value here is hard-coded or
|
||||
# lifted from the parent process.
|
||||
# lifted from the parent.
|
||||
_HF_SENSITIVE_KWARGS = frozenset(
|
||||
{
|
||||
"token",
|
||||
|
|
@ -1674,14 +1661,14 @@ def _check_signal_escape_patterns(code: str):
|
|||
def _reads_env_or_secret(node: ast.AST | None) -> bool:
|
||||
"""True if any node in the subtree resolves to an env / process read.
|
||||
|
||||
Walking the subtree (not just the root) means wrapper calls like
|
||||
`str(os.environ)`, `json.dumps(os.environ)`, or
|
||||
`'-'.join(os.environ.values())` are caught too.
|
||||
Walking the subtree (not just the root) catches wrapper calls like
|
||||
`str(os.environ)`, `json.dumps(os.environ)`,
|
||||
`'-'.join(os.environ.values())`.
|
||||
|
||||
Covers: `os.environ`, `os.environ[K]`, `os.environ.get(K)`, `os.getenv(K)`,
|
||||
bare `getenv(K)` (after `from os import getenv`), and
|
||||
`subprocess.{run,check_output,Popen,getoutput,getstatusoutput}` which
|
||||
the LLM could use to lift parent env via `printenv` / `env` / `set`.
|
||||
could lift parent env via `printenv` / `env` / `set`.
|
||||
"""
|
||||
if node is None:
|
||||
return False
|
||||
|
|
@ -1729,7 +1716,7 @@ def _check_signal_escape_patterns(code: str):
|
|||
if node is None:
|
||||
return False
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, (bytes, bytearray)):
|
||||
return True # inline bytes, no file access
|
||||
return True # inline bytes; no file access
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
||||
return _is_safe_relative_path(node.value)
|
||||
if isinstance(node, ast.Call):
|
||||
|
|
@ -1751,10 +1738,10 @@ def _check_signal_escape_patterns(code: str):
|
|||
|
||||
Policy: HF uploads are allowed only when (a) no sensitive kwarg is set,
|
||||
(b) no positional / keyword value reads `os.environ` or related env
|
||||
readers, and (c) the path argument is a sandbox-local literal -- a
|
||||
relative string with no `..`, an `open(<literal>)`, or inline bytes.
|
||||
Dynamic / variable paths are rejected; the policy cannot prove safety
|
||||
statically and the cost of a wrong-allow is a credential exfiltration.
|
||||
readers, and (c) the path arg is a sandbox-local literal: a relative
|
||||
string with no `..`, an `open(<literal>)`, or inline bytes. Dynamic /
|
||||
variable paths are rejected since safety can't be proven statically and
|
||||
a wrong-allow means credential exfiltration.
|
||||
"""
|
||||
for kw in node.keywords or []:
|
||||
if kw.arg in _HF_SENSITIVE_KWARGS:
|
||||
|
|
@ -1813,7 +1800,7 @@ def _check_signal_escape_patterns(code: str):
|
|||
}
|
||||
)
|
||||
|
||||
# Direct sock.connect((host, port)) bypasses the FQ-prefix branch below.
|
||||
# Direct sock.connect((host, port)) bypasses the FQ-prefix branch.
|
||||
if isinstance(node.func, ast.Attribute) and node.func.attr == "connect" and node.args:
|
||||
a0 = node.args[0]
|
||||
host_lit = None
|
||||
|
|
@ -1947,9 +1934,8 @@ def _check_code_safety(code: str) -> str | None:
|
|||
"""
|
||||
safe, info = _check_signal_escape_patterns(code)
|
||||
if not safe:
|
||||
# SyntaxError from ast.parse -- let these through so the subprocess
|
||||
# produces a normal Python traceback instead of a misleading
|
||||
# "unsafe code detected" message.
|
||||
# Let SyntaxError from ast.parse through so the subprocess produces a
|
||||
# normal Python traceback instead of a misleading "unsafe code" message.
|
||||
if info.get("error"):
|
||||
return None
|
||||
|
||||
|
|
@ -2032,7 +2018,7 @@ def _python_exec(
|
|||
|
||||
tmp_path = None
|
||||
workdir = _get_workdir(session_id)
|
||||
# Snapshot image mtimes so we detect both new and overwritten files.
|
||||
# Snapshot image mtimes to detect new and overwritten files.
|
||||
_before: dict[str, int] = {}
|
||||
if os.path.isdir(workdir):
|
||||
for _name in os.listdir(workdir):
|
||||
|
|
@ -2088,7 +2074,7 @@ def _python_exec(
|
|||
result = f"Exit code {proc.returncode}:\n{result}"
|
||||
result = _truncate(result) if result.strip() else "(no output)"
|
||||
|
||||
# Detect new or overwritten image files and append sentinel for frontend
|
||||
# Detect new/overwritten images and append sentinel for the frontend
|
||||
if session_id and os.path.isdir(workdir):
|
||||
new_images = []
|
||||
for _name in os.listdir(workdir):
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@
|
|||
"""
|
||||
Inference subprocess entry point.
|
||||
|
||||
Each inference session runs in a persistent subprocess (mp.get_context("spawn")).
|
||||
This gives us a clean Python interpreter with no stale module state —
|
||||
solving the transformers version-switching problem completely.
|
||||
Each inference session runs in a persistent subprocess
|
||||
(mp.get_context("spawn")), giving a clean Python interpreter with no stale
|
||||
module state — fully solving the transformers version-switching problem.
|
||||
|
||||
The subprocess stays alive while a model is loaded, accepting commands
|
||||
(generate, load, unload) via mp.Queue. It exits on shutdown or unload.
|
||||
|
|
@ -35,7 +35,7 @@ from utils.hardware import apply_gpu_ids
|
|||
|
||||
def _activate_transformers_version(model_name: str) -> None:
|
||||
"""Activate the correct transformers version BEFORE any ML imports."""
|
||||
# Ensure backend is on path for utils imports
|
||||
# Ensure backend is on path for utils imports.
|
||||
backend_path = str(Path(__file__).resolve().parent.parent.parent)
|
||||
if backend_path not in sys.path:
|
||||
sys.path.insert(0, backend_path)
|
||||
|
|
@ -96,18 +96,16 @@ def _build_model_config(config: dict):
|
|||
def _get_hf_download_state(model_names: list[str] | None = None) -> tuple[int, bool] | None:
|
||||
"""Return (total_bytes, has_incomplete) for the HF Hub cache, or None on error.
|
||||
|
||||
When *model_names* is provided, only those models' ``blobs/``
|
||||
directories are checked instead of scanning every cached model --
|
||||
much faster on systems with many models. Accepts multiple names so
|
||||
that LoRA loads can watch both the adapter repo and the base model
|
||||
repo simultaneously.
|
||||
When *model_names* is provided, only those models' ``blobs/`` dirs are
|
||||
checked instead of scanning every cached model -- much faster with many
|
||||
models. Accepts multiple names so LoRA loads can watch both the adapter
|
||||
repo and the base model repo at once.
|
||||
|
||||
*has_incomplete* is True when any ``*.incomplete`` files exist in the
|
||||
watched blobs directories, indicating that ``huggingface_hub`` is
|
||||
actively downloading.
|
||||
watched blobs dirs, indicating ``huggingface_hub`` is actively downloading.
|
||||
|
||||
Returns None if the state cannot be determined (import error,
|
||||
permission error, etc.) so callers can skip stall logic.
|
||||
Returns None if the state cannot be determined (import error, permission
|
||||
error, etc.) so callers can skip stall logic.
|
||||
"""
|
||||
try:
|
||||
from huggingface_hub.constants import HF_HUB_CACHE
|
||||
|
|
@ -127,12 +125,12 @@ def _get_hf_download_state(model_names: list[str] | None = None) -> tuple[int, b
|
|||
continue
|
||||
# Skip local filesystem paths -- HF model IDs use forward
|
||||
# slashes (org/model) but never start with / . ~ or contain
|
||||
# backslashes. This distinguishes them from absolute paths,
|
||||
# relative paths, and Windows paths.
|
||||
# backslashes, distinguishing them from absolute, relative,
|
||||
# and Windows paths.
|
||||
if name.startswith(("/", ".", "~")) or "\\" in name:
|
||||
continue
|
||||
name = resolve_cached_repo_id_case(name)
|
||||
# HF cache dir format: models--org--name (slashes -> --)
|
||||
# HF cache dir format: models--org--name (slashes -> --).
|
||||
cache_dir_name = "models--" + name.replace("/", "--")
|
||||
blobs_dir = cache / cache_dir_name / "blobs"
|
||||
if blobs_dir.exists():
|
||||
|
|
@ -165,14 +163,14 @@ def _start_heartbeat(
|
|||
) -> threading.Event:
|
||||
"""Start a daemon thread that sends periodic status heartbeats.
|
||||
|
||||
Monitors the HF Hub cache directory for download activity. A stall
|
||||
is only reported when ``*.incomplete`` files are present (indicating
|
||||
``huggingface_hub`` is actively downloading) **and** the total cache
|
||||
size has not changed for *stall_timeout* seconds.
|
||||
Monitors the HF Hub cache for download activity. A stall is reported only
|
||||
when ``*.incomplete`` files are present (``huggingface_hub`` is actively
|
||||
downloading) **and** the total cache size has not changed for
|
||||
*stall_timeout* seconds.
|
||||
|
||||
Once the download finishes (no more ``.incomplete`` files), the stall
|
||||
timer resets, so post-download initialization (quantization, GPU
|
||||
weight loading) is never misclassified as a stalled download.
|
||||
Once the download finishes (no more ``.incomplete`` files), the stall timer
|
||||
resets, so post-download init (quantization, GPU weight loading) is never
|
||||
misclassified as a stalled download.
|
||||
|
||||
Returns a stop event -- set it to terminate the heartbeat thread.
|
||||
"""
|
||||
|
|
@ -188,7 +186,7 @@ def _start_heartbeat(
|
|||
state = _get_hf_download_state(model_names)
|
||||
now = time.monotonic()
|
||||
|
||||
# Skip stall logic if we cannot measure the cache
|
||||
# Skip stall logic if we cannot measure the cache.
|
||||
if state is None:
|
||||
_send_response(
|
||||
resp_queue,
|
||||
|
|
@ -206,9 +204,8 @@ def _start_heartbeat(
|
|||
last_size = current_size
|
||||
last_change = now
|
||||
|
||||
# Only fire stall when .incomplete files are present,
|
||||
# confirming a download is actively in progress.
|
||||
# Once downloads finish (no .incomplete), reset the timer
|
||||
# Only fire stall when .incomplete files confirm a download is in
|
||||
# progress. Once downloads finish (no .incomplete), reset the timer
|
||||
# so model init time is not counted as a stall.
|
||||
if not has_incomplete:
|
||||
last_change = now
|
||||
|
|
@ -224,7 +221,7 @@ def _start_heartbeat(
|
|||
"ts": time.time(),
|
||||
},
|
||||
)
|
||||
# Only fire once -- the orchestrator will kill us
|
||||
# Only fire once -- the orchestrator will kill us.
|
||||
return
|
||||
|
||||
_send_response(
|
||||
|
|
@ -249,7 +246,7 @@ def _handle_load(backend, config: dict, resp_queue: Any) -> None:
|
|||
hf_token = config.get("hf_token")
|
||||
hf_token = hf_token if hf_token and hf_token.strip() else None
|
||||
|
||||
# Auto-detect quantization for LoRA adapters
|
||||
# Auto-detect quantization for LoRA adapters.
|
||||
load_in_4bit = config.get("load_in_4bit", True)
|
||||
if mc.is_lora and mc.path:
|
||||
import json
|
||||
|
|
@ -280,7 +277,7 @@ def _handle_load(backend, config: dict, resp_queue: Any) -> None:
|
|||
except Exception as e:
|
||||
logger.warning("Could not read adapter_config.json: %s", e)
|
||||
|
||||
# Auto-enable trust_remote_code for NemotronH/Nano models only.
|
||||
# Auto-enable trust_remote_code for NemotronH/Nano models only:
|
||||
# NemotronH has config parsing bugs requiring trust_remote_code=True.
|
||||
# Other transformers 5.x models are native and do NOT need it.
|
||||
# NOTE: Must NOT match Llama-Nemotron (standard Llama architecture).
|
||||
|
|
@ -298,12 +295,12 @@ def _handle_load(backend, config: dict, resp_queue: Any) -> None:
|
|||
model_name,
|
||||
)
|
||||
|
||||
# Send heartbeats every 30s so the orchestrator knows we're still alive
|
||||
# (download / weight loading can take a long time on slow connections)
|
||||
# Send heartbeats every 30s so the orchestrator knows we're alive
|
||||
# (download/weight loading can be slow on slow connections).
|
||||
xet_disabled = os.environ.get("HF_HUB_DISABLE_XET") == "1"
|
||||
|
||||
# Watch both the model repo and base model repo (for LoRA loads
|
||||
# where the base model download is the actual bottleneck)
|
||||
# Watch both the model repo and base model repo (for LoRA loads where
|
||||
# the base model download is the real bottleneck).
|
||||
watch_repos = [mc.identifier]
|
||||
base = getattr(mc, "base_model", None)
|
||||
if base and str(base) != mc.identifier:
|
||||
|
|
@ -328,7 +325,7 @@ def _handle_load(backend, config: dict, resp_queue: Any) -> None:
|
|||
heartbeat_stop.set()
|
||||
|
||||
if success:
|
||||
# Build model_info for the parent to mirror
|
||||
# Build model_info for the parent to mirror.
|
||||
model_info = {
|
||||
"identifier": mc.identifier,
|
||||
"display_name": mc.display_name,
|
||||
|
|
@ -341,8 +338,8 @@ def _handle_load(backend, config: dict, resp_queue: Any) -> None:
|
|||
"audio_type": getattr(mc, "audio_type", None),
|
||||
"has_audio_input": getattr(mc, "has_audio_input", False),
|
||||
}
|
||||
# Forward chat_template_info so the parent can classify
|
||||
# capabilities without re-entering the subprocess.
|
||||
# Forward chat_template_info so the parent can classify capabilities
|
||||
# without re-entering the subprocess.
|
||||
try:
|
||||
_bm = getattr(backend, "models", {}) or {}
|
||||
_entry = (
|
||||
|
|
@ -397,22 +394,21 @@ def _handle_load(backend, config: dict, resp_queue: Any) -> None:
|
|||
def _handle_generate(backend, cmd: dict, resp_queue: Any, cancel_event) -> None:
|
||||
"""Handle a generate command: stream tokens back via resp_queue.
|
||||
|
||||
cancel_event is an mp.Event shared with the parent process.
|
||||
The parent can set it at any time (e.g. user stops generation,
|
||||
or user loads a new model while generating) and generation
|
||||
stops within 1-2 tokens.
|
||||
cancel_event is an mp.Event shared with the parent. The parent can set it
|
||||
at any time (e.g. user stops generation, or loads a new model mid-generate)
|
||||
and generation stops within 1-2 tokens.
|
||||
"""
|
||||
request_id = cmd.get("request_id", "")
|
||||
|
||||
try:
|
||||
# Decode image if provided
|
||||
# Decode image if provided.
|
||||
image = None
|
||||
image_b64 = cmd.get("image_base64")
|
||||
if image_b64:
|
||||
image = _decode_image(image_b64)
|
||||
image = _resize_image(image)
|
||||
|
||||
# Build generation kwargs
|
||||
# Build generation kwargs.
|
||||
gen_kwargs = {
|
||||
"messages": cmd["messages"],
|
||||
"system_prompt": cmd.get("system_prompt", ""),
|
||||
|
|
@ -426,9 +422,8 @@ def _handle_generate(backend, cmd: dict, resp_queue: Any, cancel_event) -> None:
|
|||
"cancel_event": cancel_event,
|
||||
}
|
||||
|
||||
# Optional template/tool plumbing: only forward keys that are
|
||||
# actually present so the backend signature can evolve without
|
||||
# breaking older command payloads.
|
||||
# Optional template/tool plumbing: only forward keys that are present so
|
||||
# the backend signature can evolve without breaking older payloads.
|
||||
for opt_key in (
|
||||
"tools",
|
||||
"enable_thinking",
|
||||
|
|
@ -438,7 +433,7 @@ def _handle_generate(backend, cmd: dict, resp_queue: Any, cancel_event) -> None:
|
|||
if opt_key in cmd:
|
||||
gen_kwargs[opt_key] = cmd[opt_key]
|
||||
|
||||
# Choose generation path
|
||||
# Choose generation path.
|
||||
use_adapter = cmd.get("use_adapter")
|
||||
if use_adapter is not None:
|
||||
generator = backend.generate_with_adapter_control(
|
||||
|
|
@ -451,7 +446,7 @@ def _handle_generate(backend, cmd: dict, resp_queue: Any, cancel_event) -> None:
|
|||
logger.info("Starting text generation for request_id=%s", request_id)
|
||||
|
||||
for cumulative_text in generator:
|
||||
# cancel_event is an mp.Event — checked instantly, no queue polling
|
||||
# cancel_event is an mp.Event — checked instantly, no queue polling.
|
||||
if cancel_event.is_set():
|
||||
logger.info("Generation cancelled for request %s", request_id)
|
||||
break
|
||||
|
|
@ -508,7 +503,7 @@ def _handle_generate_audio(backend, cmd: dict, resp_queue: Any) -> None:
|
|||
use_adapter = cmd.get("use_adapter"),
|
||||
)
|
||||
|
||||
# Send WAV bytes as base64 (bytes can't go through mp.Queue directly)
|
||||
# Send WAV bytes as base64 (bytes can't go through mp.Queue directly).
|
||||
_send_response(
|
||||
resp_queue,
|
||||
{
|
||||
|
|
@ -542,7 +537,7 @@ def _handle_generate_audio_input(backend, cmd: dict, resp_queue: Any, cancel_eve
|
|||
try:
|
||||
import numpy as np
|
||||
|
||||
# Decode audio array from list (numpy arrays can't go through mp.Queue)
|
||||
# Decode audio array from list (numpy arrays can't go through mp.Queue).
|
||||
audio_array = np.array(cmd["audio_data"], dtype = np.float32)
|
||||
|
||||
audio_type = cmd.get("audio_type")
|
||||
|
|
@ -638,12 +633,12 @@ def _handle_unload(backend, cmd: dict, resp_queue: Any) -> None:
|
|||
|
||||
|
||||
def run_inference_process(*, cmd_queue: Any, resp_queue: Any, cancel_event, config: dict) -> None:
|
||||
"""Subprocess entrypoint. Persistent — runs command loop until shutdown.
|
||||
"""Subprocess entrypoint. Persistent — runs the command loop until shutdown.
|
||||
|
||||
Args:
|
||||
cmd_queue: mp.Queue for receiving commands from parent.
|
||||
resp_queue: mp.Queue for sending responses to parent.
|
||||
cancel_event: mp.Event shared with parent — set by parent to cancel generation.
|
||||
cancel_event: mp.Event the parent sets to cancel generation.
|
||||
config: Initial configuration dict with model info.
|
||||
"""
|
||||
os.environ["TOKENIZERS_PARALLELISM"] = "false"
|
||||
|
|
@ -668,7 +663,7 @@ def run_inference_process(*, cmd_queue: Any, resp_queue: Any, cancel_event, conf
|
|||
|
||||
model_name = config["model_name"]
|
||||
|
||||
# ── 0. MLX fast-path — skip torch/transformers entirely ──
|
||||
# ── 0. MLX fast-path — skip torch/transformers ──
|
||||
backend_path = str(Path(__file__).resolve().parent.parent.parent)
|
||||
if backend_path not in sys.path:
|
||||
sys.path.insert(0, backend_path)
|
||||
|
|
@ -702,7 +697,7 @@ def run_inference_process(*, cmd_queue: Any, resp_queue: Any, cancel_event, conf
|
|||
)
|
||||
return
|
||||
|
||||
# Enter same command loop as GPU path
|
||||
# Enter the same command loop as the GPU path.
|
||||
logger.info("MLX inference subprocess ready, entering command loop")
|
||||
while True:
|
||||
try:
|
||||
|
|
@ -760,7 +755,7 @@ def run_inference_process(*, cmd_queue: Any, resp_queue: Any, cancel_event, conf
|
|||
)
|
||||
return
|
||||
|
||||
# ── 1. Activate correct transformers version BEFORE any ML imports ──
|
||||
# ── 1. Activate transformers version BEFORE any ML imports ──
|
||||
try:
|
||||
_activate_transformers_version(model_name)
|
||||
except Exception as exc:
|
||||
|
|
@ -775,7 +770,7 @@ def run_inference_process(*, cmd_queue: Any, resp_queue: Any, cancel_event, conf
|
|||
)
|
||||
return
|
||||
|
||||
# ── 1b. On Windows, check Triton availability (must be before import torch) ──
|
||||
# ── 1b. Windows: check Triton availability (must precede import torch) ──
|
||||
if sys.platform == "win32":
|
||||
try:
|
||||
import triton # noqa: F401
|
||||
|
|
@ -848,8 +843,8 @@ def run_inference_process(*, cmd_queue: Any, resp_queue: Any, cancel_event, conf
|
|||
return
|
||||
|
||||
# ── 4. Command loop — process commands until shutdown ──
|
||||
# cancel_event is an mp.Event shared with parent — parent can set it
|
||||
# at any time to cancel generation instantly (no queue polling needed).
|
||||
# cancel_event is an mp.Event the parent can set anytime to cancel
|
||||
# generation instantly (no queue polling needed).
|
||||
logger.info("Inference subprocess ready, entering command loop")
|
||||
|
||||
while True:
|
||||
|
|
@ -873,8 +868,7 @@ def run_inference_process(*, cmd_queue: Any, resp_queue: Any, cancel_event, conf
|
|||
_handle_generate(backend, cmd, resp_queue, cancel_event)
|
||||
|
||||
elif cmd_type == "load":
|
||||
# Load a new model (reusing this subprocess)
|
||||
# First unload current model
|
||||
# Load a new model in this subprocess; unload the current one first.
|
||||
if backend.active_model_name:
|
||||
backend.unload_model(backend.active_model_name)
|
||||
_handle_load(backend, cmd, resp_queue)
|
||||
|
|
@ -891,7 +885,7 @@ def run_inference_process(*, cmd_queue: Any, resp_queue: Any, cancel_event, conf
|
|||
_handle_unload(backend, cmd, resp_queue)
|
||||
|
||||
elif cmd_type == "cancel":
|
||||
# Redundant with mp.Event but handle gracefully
|
||||
# Redundant with mp.Event but handle gracefully.
|
||||
cancel_event.set()
|
||||
logger.info("Cancel command received")
|
||||
|
||||
|
|
@ -907,7 +901,7 @@ def run_inference_process(*, cmd_queue: Any, resp_queue: Any, cancel_event, conf
|
|||
)
|
||||
|
||||
elif cmd_type == "status":
|
||||
# Return current status
|
||||
# Return current status.
|
||||
_send_response(
|
||||
resp_queue,
|
||||
{
|
||||
|
|
@ -927,7 +921,7 @@ def run_inference_process(*, cmd_queue: Any, resp_queue: Any, cancel_event, conf
|
|||
|
||||
elif cmd_type == "shutdown":
|
||||
logger.info("Shutdown command received, exiting")
|
||||
# Unload all models
|
||||
# Unload all models.
|
||||
for model_name in list(backend.models.keys()):
|
||||
try:
|
||||
backend.unload_model(model_name)
|
||||
|
|
|
|||
|
|
@ -3,24 +3,24 @@
|
|||
|
||||
"""Tool-call XML parsing and stripping helpers.
|
||||
|
||||
Extracted verbatim from studio/backend/core/inference/llama_cpp.py so that
|
||||
Extracted verbatim from studio/backend/core/inference/llama_cpp.py so
|
||||
external inference servers (llama-server wrappers, llama-swap, custom
|
||||
shims) can reuse the same logic without importing the inference
|
||||
orchestrator, structlog, httpx, or the rest of the studio backend.
|
||||
shims) can reuse the logic without importing the inference orchestrator,
|
||||
structlog, httpx, or the rest of the studio backend.
|
||||
|
||||
The regexes and function bodies are byte-for-byte identical to the
|
||||
original inline implementation in llama_cpp.py. Any change made here must
|
||||
preserve that equivalence; tests/python/test_tool_healing_extraction_is_exact.py
|
||||
Regexes and function bodies are byte-for-byte identical to the original
|
||||
inline implementation in llama_cpp.py; any change must preserve that
|
||||
equivalence. tests/python/test_tool_healing_extraction_is_exact.py
|
||||
verifies it with AST comparison.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
|
||||
# Pre-compiled patterns for tool XML stripping. Hyphen in the
|
||||
# function/parameter name char-class tracks OpenAI's allowed set so
|
||||
# MCP tool names with dashes (mcp__srv__list-issues) and parameter
|
||||
# names with dashes (`issue-number`) parse alongside the built-ins.
|
||||
# Pre-compiled patterns for tool XML stripping. The hyphen in the
|
||||
# function/parameter name char-class tracks OpenAI's allowed set so MCP
|
||||
# tool names (mcp__srv__list-issues) and parameter names (`issue-number`)
|
||||
# with dashes parse alongside the built-ins.
|
||||
_TOOL_CLOSED_PATS = [
|
||||
re.compile(r"<tool_call>.*?</tool_call>", re.DOTALL),
|
||||
re.compile(r"<function=[\w-]+>.*?</function>", re.DOTALL),
|
||||
|
|
@ -46,13 +46,13 @@ def parse_tool_calls_from_text(content: str) -> list[dict]:
|
|||
Handles formats like:
|
||||
<tool_call>{"name":"web_search","arguments":{"query":"..."}}</tool_call>
|
||||
<tool_call><function=web_search><parameter=query>...</parameter></function></tool_call>
|
||||
Closing tags (</tool_call>, </function>, </parameter>) are all optional
|
||||
since models frequently omit them.
|
||||
Closing tags (</tool_call>, </function>, </parameter>) are all
|
||||
optional since models frequently omit them.
|
||||
"""
|
||||
tool_calls = []
|
||||
|
||||
# Pattern 1: JSON inside <tool_call> tags.
|
||||
# Use balanced-brace extraction that skips braces inside JSON strings.
|
||||
# Pattern 1: JSON inside <tool_call> tags. Balanced-brace extraction
|
||||
# that skips braces inside JSON strings.
|
||||
for m in _TC_JSON_START_RE.finditer(content):
|
||||
brace_start = m.end() - 1 # position of the opening {
|
||||
depth, i = 0, brace_start
|
||||
|
|
@ -96,11 +96,10 @@ def parse_tool_calls_from_text(content: str) -> list[dict]:
|
|||
# All closing tags optional -- models frequently omit </parameter>,
|
||||
# </function>, and/or </tool_call>.
|
||||
if not tool_calls:
|
||||
# Step 1: Find all <function=name> positions and extract their bodies.
|
||||
# Body boundary: use only </tool_call> or next <function= as hard
|
||||
# boundaries. We avoid using </function> as a boundary because
|
||||
# code parameter values can contain that literal string.
|
||||
# After extracting, we trim a trailing </function> if present.
|
||||
# Step 1: Find all <function=name> positions and extract bodies.
|
||||
# Use only </tool_call> or the next <function= as hard boundaries;
|
||||
# </function> is avoided since code values can contain it. Trim a
|
||||
# trailing </function> afterwards if present.
|
||||
func_starts = list(_TC_FUNC_START_RE.finditer(content))
|
||||
for idx, fm in enumerate(func_starts):
|
||||
func_name = fm.group(1)
|
||||
|
|
@ -114,18 +113,18 @@ def parse_tool_calls_from_text(content: str) -> list[dict]:
|
|||
body_end = len(content)
|
||||
body_end = min(body_end, next_func)
|
||||
body = content[body_start:body_end]
|
||||
# Trim trailing </function> if present (it's the real closing tag)
|
||||
# Trim the real closing </function> tag if present
|
||||
body = _TC_FUNC_CLOSE_RE.sub("", body)
|
||||
|
||||
# Step 2: Extract parameters from body.
|
||||
# For single-parameter functions (the common case: code, command,
|
||||
# query), use body end as the only boundary to avoid false matches
|
||||
# on </parameter> inside code strings.
|
||||
# Step 2: Extract parameters from body. For single-parameter
|
||||
# functions (the common code/command/query case), use body end
|
||||
# as the only boundary to avoid matching </parameter> inside
|
||||
# code strings.
|
||||
arguments = {}
|
||||
param_starts = list(_TC_PARAM_START_RE.finditer(body))
|
||||
if len(param_starts) == 1:
|
||||
# Single parameter: value is everything from after the tag
|
||||
# to end of body, trimming any trailing </parameter>.
|
||||
# Single parameter: value is everything after the tag to
|
||||
# end of body, trimming any trailing </parameter>.
|
||||
pm = param_starts[0]
|
||||
val = body[pm.end() :]
|
||||
val = _TC_PARAM_CLOSE_RE.sub("", val)
|
||||
|
|
@ -134,7 +133,7 @@ def parse_tool_calls_from_text(content: str) -> list[dict]:
|
|||
for pidx, pm in enumerate(param_starts):
|
||||
param_name = pm.group(1)
|
||||
val_start = pm.end()
|
||||
# Value ends at next <parameter= or end of body
|
||||
# Value ends at the next <parameter= or end of body
|
||||
next_param = (
|
||||
param_starts[pidx + 1].start()
|
||||
if pidx + 1 < len(param_starts)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""
|
||||
Training submodule - Training backends and trainer classes
|
||||
"""
|
||||
"""Training submodule: backends and trainer classes."""
|
||||
|
||||
from .training import TrainingBackend, TrainingProgress, get_training_backend
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -4,12 +4,12 @@
|
|||
"""
|
||||
Training backend — subprocess orchestrator.
|
||||
|
||||
Each training job runs in a fresh subprocess (mp.get_context("spawn")),
|
||||
solving the transformers version-switching problem. The old in-process
|
||||
UnslothTrainer singleton is only used inside the subprocess (worker.py).
|
||||
Each training job runs in a fresh subprocess (mp.get_context("spawn")), solving
|
||||
the transformers version-switching problem. The old in-process UnslothTrainer
|
||||
singleton is only used inside the subprocess (worker.py).
|
||||
|
||||
This file orchestrates the subprocess lifecycle, pumps events from the
|
||||
worker's mp.Queue, and exposes the same API surface to routes/training.py.
|
||||
This file orchestrates the subprocess lifecycle, pumps events from the worker's
|
||||
mp.Queue, and exposes the same API surface to routes/training.py.
|
||||
|
||||
Pattern follows core/data_recipe/jobs/manager.py.
|
||||
"""
|
||||
|
|
@ -47,9 +47,9 @@ _HF_TMP_CHECKPOINT_RE = re.compile(r"^tmp-checkpoint-\d+$")
|
|||
def _cleanup_cancelled_checkpoints(output_dir: str | os.PathLike) -> None:
|
||||
"""Remove only HF Trainer ``tmp-checkpoint-<step>/`` partials after a cancel.
|
||||
|
||||
Completed ``checkpoint-<int>/`` dirs and any non-numeric-suffix tmp dir
|
||||
are user-owned and survive. Symlinked output_dir / children are skipped
|
||||
so containment cannot be bypassed.
|
||||
Completed ``checkpoint-<int>/`` dirs and any non-numeric-suffix tmp dir are
|
||||
user-owned and survive. Symlinked output_dir / children are skipped so
|
||||
containment can't be bypassed.
|
||||
"""
|
||||
out = Path(output_dir)
|
||||
if not out.exists() or not out.is_dir() or out.is_symlink():
|
||||
|
|
@ -95,8 +95,8 @@ PLOT_HEIGHT = 3.5
|
|||
|
||||
@dataclass
|
||||
class TrainingProgress:
|
||||
"""Mirror of trainer.TrainingProgress — kept here so the parent process
|
||||
never needs to import the heavy ML modules."""
|
||||
"""Mirror of trainer.TrainingProgress — here so the parent process never
|
||||
imports the heavy ML modules."""
|
||||
|
||||
epoch: float = 0
|
||||
step: int = 0
|
||||
|
|
@ -118,7 +118,7 @@ class TrainingProgress:
|
|||
class TrainingBackend:
|
||||
"""
|
||||
Training orchestration backend — subprocess-based.
|
||||
Launches a fresh subprocess per training job, communicates via mp.Queue.
|
||||
Launches a fresh subprocess per job, communicates via mp.Queue.
|
||||
"""
|
||||
|
||||
FLUSH_THRESHOLD: int = 10
|
||||
|
|
@ -136,7 +136,7 @@ class TrainingBackend:
|
|||
self._should_stop = False
|
||||
self._cancel_requested = False # True only for stop(save=False)
|
||||
|
||||
# Training Metrics (consumed by routes for SSE and /metrics)
|
||||
# Training metrics (consumed by routes for SSE and /metrics)
|
||||
self.loss_history: list = []
|
||||
self.lr_history: list = []
|
||||
self.step_history: list = []
|
||||
|
|
@ -169,7 +169,7 @@ class TrainingBackend:
|
|||
"""Spawn a subprocess to run the full training pipeline.
|
||||
|
||||
All kwargs are serialized into a config dict and sent to the worker.
|
||||
Returns True if the subprocess was started successfully.
|
||||
Returns True if the subprocess started successfully.
|
||||
"""
|
||||
with self._lock:
|
||||
if self._proc is not None and self._proc.is_alive():
|
||||
|
|
@ -244,8 +244,8 @@ class TrainingBackend:
|
|||
"gpu_ids": kwargs.get("gpu_ids"),
|
||||
}
|
||||
|
||||
# Full finetuning always runs in 16-bit. LoRA/QLoRA and CPT preserve the
|
||||
# explicit request so 4-bit adapter/raw-text runs remain possible.
|
||||
# Full finetuning always runs in 16-bit. LoRA/QLoRA and CPT keep the
|
||||
# explicit request so 4-bit adapter/raw-text runs stay possible.
|
||||
if config["training_type"] == "Full Finetuning":
|
||||
config["load_in_4bit"] = False
|
||||
|
||||
|
|
@ -296,8 +296,7 @@ class TrainingBackend:
|
|||
|
||||
logger.info("Training subprocess started (pid=%s)", proc.pid)
|
||||
|
||||
# Reset state — safe because old pump thread is confirmed dead
|
||||
# and proc.start() succeeded
|
||||
# Reset state — safe: old pump thread confirmed dead, proc.start() succeeded
|
||||
self.current_job_id = job_id
|
||||
self._should_stop = False
|
||||
self._cancel_requested = False
|
||||
|
|
@ -325,7 +324,7 @@ class TrainingBackend:
|
|||
self._stop_queue = stop_queue
|
||||
self._proc = proc
|
||||
|
||||
# Eagerly create DB run row so the run appears in history during model loading
|
||||
# Eagerly create DB run row so it appears in history during model loading
|
||||
self._ensure_db_run_created()
|
||||
|
||||
# Start event pump thread
|
||||
|
|
@ -368,7 +367,7 @@ class TrainingBackend:
|
|||
proc.join(timeout = 2.0)
|
||||
|
||||
# Wait for pump thread to finish DB finalization before returning
|
||||
# (8s covers SQLite's default 5s lock timeout plus execution overhead)
|
||||
# (8s covers SQLite's 5s lock timeout plus execution overhead)
|
||||
if self._pump_thread is not None and self._pump_thread.is_alive():
|
||||
self._pump_thread.join(timeout = 8.0)
|
||||
|
||||
|
|
@ -399,7 +398,7 @@ class TrainingBackend:
|
|||
if p.is_completed or p.error:
|
||||
return False
|
||||
|
||||
# Check status message for activity indicators
|
||||
# Check status message for activity
|
||||
status_lower = (p.status_message or "").lower()
|
||||
if any(
|
||||
k in status_lower
|
||||
|
|
@ -530,9 +529,8 @@ class TrainingBackend:
|
|||
def _handle_event(self, event: dict) -> None:
|
||||
"""Apply a subprocess event to local state.
|
||||
|
||||
State updates happen inside self._lock; DB I/O happens after
|
||||
releasing it so status-polling API endpoints are never blocked
|
||||
by slow SQLite writes.
|
||||
State updates happen inside self._lock; DB I/O happens after releasing
|
||||
it so status-polling endpoints aren't blocked by slow SQLite writes.
|
||||
"""
|
||||
etype = event.get("type")
|
||||
db_action: Optional[str] = None
|
||||
|
|
@ -542,7 +540,7 @@ class TrainingBackend:
|
|||
if etype == "progress":
|
||||
self._progress.step = event.get("step", self._progress.step)
|
||||
self._progress.epoch = event.get("epoch", self._progress.epoch)
|
||||
# loss/lr are sanitized below; update progress after coercion
|
||||
# loss/lr sanitized below; update progress after coercion
|
||||
_raw_loss = event.get("loss")
|
||||
_raw_lr = event.get("learning_rate")
|
||||
try:
|
||||
|
|
@ -580,7 +578,7 @@ class TrainingBackend:
|
|||
if status:
|
||||
self._progress.status_message = status
|
||||
|
||||
# Update metric histories — reuse sanitized values from above
|
||||
# Update metric histories — reuse sanitized values above
|
||||
step = event.get("step", 0)
|
||||
loss = _safe_loss
|
||||
lr = _safe_lr
|
||||
|
|
@ -616,7 +614,7 @@ class TrainingBackend:
|
|||
else:
|
||||
eval_loss = None
|
||||
|
||||
# Buffer metric for DB flush (loss/lr already sanitized above)
|
||||
# Buffer metric for DB flush (loss/lr already sanitized)
|
||||
self._metric_buffer.append(
|
||||
{
|
||||
"step": step,
|
||||
|
|
@ -630,7 +628,7 @@ class TrainingBackend:
|
|||
}
|
||||
)
|
||||
|
||||
# Decide which DB action to take after releasing the lock
|
||||
# Pick the DB action to run after releasing the lock
|
||||
if not self._db_run_created and self.current_job_id and self._db_config:
|
||||
db_action = "create_run"
|
||||
db_action_kwargs = {
|
||||
|
|
@ -784,14 +782,14 @@ class TrainingBackend:
|
|||
"""Flush buffered metrics to the database and update live progress."""
|
||||
if not self._metric_buffer or not self.current_job_id or not self._db_run_created:
|
||||
return
|
||||
# Cap buffer to prevent unbounded memory growth
|
||||
# Cap buffer to bound memory growth
|
||||
if len(self._metric_buffer) > 500:
|
||||
logger.warning(
|
||||
"Metric buffer exceeded 500 entries (%d) — trimming oldest",
|
||||
len(self._metric_buffer),
|
||||
)
|
||||
self._metric_buffer = self._metric_buffer[-500:]
|
||||
# Snapshot before insert so metrics arriving during the write are preserved
|
||||
# Snapshot before insert so metrics arriving during the write survive
|
||||
batch = list(self._metric_buffer)
|
||||
try:
|
||||
from storage.studio_db import insert_metrics_batch, update_run_progress
|
||||
|
|
@ -831,7 +829,7 @@ class TrainingBackend:
|
|||
return events
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Plot generation (unchanged from original)
|
||||
# Plot generation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _create_loss_plot(
|
||||
|
|
@ -954,9 +952,9 @@ class TrainingBackend:
|
|||
def _transfer_to_inference_backend(self) -> bool:
|
||||
"""Transfer model to inference backend.
|
||||
|
||||
With subprocess-based training, the model lives in the subprocess
|
||||
and is freed when it exits. Inference must load from the saved
|
||||
checkpoint on disk. This is a no-op placeholder.
|
||||
With subprocess-based training, the model lives in the subprocess and is
|
||||
freed when it exits. Inference must load from the saved checkpoint on
|
||||
disk. No-op placeholder.
|
||||
"""
|
||||
logger.info(
|
||||
"_transfer_to_inference_backend: subprocess training — "
|
||||
|
|
|
|||
|
|
@ -4,11 +4,9 @@
|
|||
"""
|
||||
Training subprocess entry point.
|
||||
|
||||
Each training job runs in a fresh subprocess (mp.get_context("spawn")).
|
||||
This gives us a clean Python interpreter with no stale module state —
|
||||
solving the transformers version-switching problem completely.
|
||||
|
||||
Pattern follows core/data_recipe/jobs/worker.py.
|
||||
Each job runs in a fresh subprocess (mp.get_context("spawn")): a clean
|
||||
interpreter with no stale module state, which solves transformers
|
||||
version-switching. Pattern follows core/data_recipe/jobs/worker.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -57,7 +55,7 @@ _FLASH_ATTN_SKIP_ENV = "UNSLOTH_STUDIO_SKIP_FLASHATTN_INSTALL"
|
|||
_TILELANG_PACKAGE_VERSION = "0.1.8"
|
||||
_APACHE_TVM_FFI_PACKAGE_VERSION = "0.1.9"
|
||||
_TILELANG_SKIP_ENV = "UNSLOTH_STUDIO_SKIP_TILELANG_INSTALL"
|
||||
# Pin both so plain pip cannot silently upgrade torch under the worker (fla-core needs torch>=2.7).
|
||||
# Pin both so plain pip can't silently upgrade torch under the worker (fla-core needs torch>=2.7).
|
||||
_FLA_PACKAGE_VERSION = "0.5.0"
|
||||
_FLA_CORE_PACKAGE_VERSION = "0.5.0"
|
||||
_FLA_SKIP_ENV = "UNSLOTH_STUDIO_SKIP_FLA_INSTALL"
|
||||
|
|
@ -72,14 +70,13 @@ _TVM_FFI_BROKEN_VERSIONS = ("0.1.10", "0.1.11")
|
|||
_FAST_PATH_HOOKS_SKIP_ENV = "UNSLOTH_STUDIO_SKIP_FAST_PATH_HOOKS"
|
||||
|
||||
# Module-level handle so the torch.library.Library registration survives past
|
||||
# run_training_process() and is not garbage collected mid-run.
|
||||
# run_training_process() and isn't GC'd mid-run.
|
||||
_WINDOWS_ROCM_GROUPED_MM_LIB = None
|
||||
|
||||
# Worker subprocesses inherit the parent env but not the parent's
|
||||
# os.add_dll_directory registrations. Replicate main.py's Windows ROCm DLL
|
||||
# setup at module load so the first `import torch` can find amdhip64.dll even
|
||||
# when HIP_PATH\bin is not on the system PATH. Handles retained at module
|
||||
# scope so they are not garbage collected.
|
||||
# Worker subprocesses inherit the parent env but not its os.add_dll_directory
|
||||
# registrations. Replicate main.py's Windows ROCm DLL setup at module load so
|
||||
# the first `import torch` finds amdhip64.dll even when HIP_PATH\bin is not on
|
||||
# PATH. Handles retained at module scope so they aren't GC'd.
|
||||
_ROCM_DLL_HANDLES: list = []
|
||||
if sys.platform == "win32":
|
||||
|
||||
|
|
@ -147,21 +144,20 @@ def _model_wants_causal_conv1d(model_name: str) -> bool:
|
|||
|
||||
def _hipcc_gcc_install_dir() -> str | None:
|
||||
"""Return the highest-numbered ``/usr/lib/gcc/x86_64-linux-gnu/<N>`` that has
|
||||
BOTH the gcc runtime dir AND the corresponding ``/usr/include/c++/<N>`` C++
|
||||
headers, or ``None`` if no match (or non-Linux / non-x86_64).
|
||||
BOTH the gcc runtime dir AND ``/usr/include/c++/<N>`` C++ headers, or
|
||||
``None`` if no match (or non-Linux / non-x86_64).
|
||||
|
||||
Ubuntu 24.04 ships ``/usr/lib/gcc/x86_64-linux-gnu/14/`` (gcc-14 runtime
|
||||
objects) but does NOT ship ``/usr/include/c++/14`` in its default apt set;
|
||||
libstdc++ headers come from ``libstdc++-13-dev``. ROCm clang-20 picks the
|
||||
highest-numbered runtime dir by default, finds no ``<cstdlib>``, and the
|
||||
HIP source build fails with::
|
||||
Ubuntu 24.04 ships gcc-14 runtime objects but not ``/usr/include/c++/14`` in
|
||||
its default apt set (libstdc++ headers come from ``libstdc++-13-dev``). ROCm
|
||||
clang-20 picks the highest-numbered runtime dir, finds no ``<cstdlib>``, and
|
||||
the HIP source build fails with::
|
||||
|
||||
/opt/rocm-X.Y/lib/llvm/lib/clang/20/include/__clang_hip_runtime_wrapper.h:112:10:
|
||||
fatal error: 'cstdlib' file not found
|
||||
|
||||
Returning a path lets the caller pass ``--gcc-install-dir=<path>`` to clang
|
||||
via ``HIPCC_COMPILE_FLAGS_APPEND``. Mirrors the same loop ``bbf004c`` added
|
||||
to ``studio/setup.sh`` for the llama.cpp HIP build branch (PR #5301).
|
||||
via ``HIPCC_COMPILE_FLAGS_APPEND``. Mirrors the loop ``bbf004c`` added to
|
||||
``studio/setup.sh`` for the llama.cpp HIP build (PR #5301).
|
||||
"""
|
||||
if not sys.platform.startswith("linux"):
|
||||
return None
|
||||
|
|
@ -300,11 +296,10 @@ def _install_package_wheel_first(
|
|||
pypi_spec,
|
||||
]
|
||||
|
||||
# Source compilation on ROCm can take 10-30 minutes; use a generous
|
||||
# timeout. Non-HIP installs preserve the pre-existing "no timeout"
|
||||
# behaviour so unrelated slow installs (e.g. causal-conv1d source
|
||||
# build on Linux aarch64 or unsupported torch/CUDA combinations)
|
||||
# are not aborted at 5 minutes by this PR.
|
||||
# ROCm source compilation can take 10-30 min; use a generous timeout.
|
||||
# Non-HIP installs keep the pre-existing "no timeout" behaviour so unrelated
|
||||
# slow installs (e.g. causal-conv1d source build on Linux aarch64, or
|
||||
# unsupported torch/CUDA combos) aren't aborted at 5 minutes.
|
||||
_run_kwargs: dict[str, Any] = {
|
||||
"stdout": _sp.PIPE,
|
||||
"stderr": _sp.STDOUT,
|
||||
|
|
@ -313,15 +308,14 @@ def _install_package_wheel_first(
|
|||
if is_hip:
|
||||
_run_kwargs["timeout"] = 1800
|
||||
# On Ubuntu 24.04 + ROCm clang-20, the HIP source build (causal-conv1d,
|
||||
# mamba-ssm source fallback, flash-attn source fallback) defaults to
|
||||
# mamba-ssm / flash-attn source fallbacks) defaults to
|
||||
# /usr/lib/gcc/x86_64-linux-gnu/14/ which has the runtime dir but no
|
||||
# /usr/include/c++/14 headers, and dies at:
|
||||
# __clang_hip_runtime_wrapper.h:112:10:
|
||||
# fatal error: 'cstdlib' file not found
|
||||
# Inject --gcc-install-dir for a gcc whose C++ headers actually exist.
|
||||
# Respect any pre-existing --gcc-install-dir in HIPCC_COMPILE_FLAGS_APPEND
|
||||
# (user knows best); otherwise append. Mirrors the same fix bbf004c
|
||||
# added to studio/setup.sh for the llama.cpp HIP build (PR #5301).
|
||||
# Inject --gcc-install-dir for a gcc whose C++ headers exist. Respect any
|
||||
# pre-existing --gcc-install-dir in HIPCC_COMPILE_FLAGS_APPEND; else
|
||||
# append. Mirrors fix bbf004c in studio/setup.sh (llama.cpp HIP, PR #5301).
|
||||
_existing_flags = os.environ.get("HIPCC_COMPILE_FLAGS_APPEND", "")
|
||||
if "--gcc-install-dir" not in _existing_flags:
|
||||
_gcc_dir = _hipcc_gcc_install_dir()
|
||||
|
|
@ -369,9 +363,9 @@ def _install_package_wheel_first(
|
|||
)
|
||||
else:
|
||||
if sys.platform == "win32":
|
||||
# No prebuilt wheel and no source build toolchain on Windows --
|
||||
# this is expected for packages like causal-conv1d. Log at
|
||||
# info so users aren't alarmed by what looks like an error.
|
||||
# No prebuilt wheel and no source toolchain on Windows --
|
||||
# expected for packages like causal-conv1d. Log at info so
|
||||
# users aren't alarmed by what looks like an error.
|
||||
logger.info(
|
||||
"%s is not available on Windows (no prebuilt wheel); skipping",
|
||||
display_name,
|
||||
|
|
@ -487,7 +481,7 @@ def _ensure_flash_linear_attention_unconditional(event_queue: Any) -> bool:
|
|||
)
|
||||
return False
|
||||
|
||||
# Probe once; reuse result so the --force-reinstall decision and the short-circuit
|
||||
# Probe once; reuse so the --force-reinstall decision and the short-circuit
|
||||
# share the same call count (stable for tests).
|
||||
already_importable = _flash_linear_attention_importable()
|
||||
if already_importable and _flash_linear_attention_current(already_importable = True):
|
||||
|
|
@ -499,7 +493,7 @@ def _ensure_flash_linear_attention_unconditional(event_queue: Any) -> bool:
|
|||
f"Installing flash-linear-attention=={_FLA_PACKAGE_VERSION} for faster training...",
|
||||
)
|
||||
|
||||
# `--no-deps` blocks the silent torch upgrade; we bring the non-torch runtime deps in by hand.
|
||||
# `--no-deps` blocks the silent torch upgrade; bring non-torch runtime deps in by hand.
|
||||
specs = [
|
||||
*_FLA_RUNTIME_DEPS,
|
||||
f"fla-core=={_FLA_CORE_PACKAGE_VERSION}",
|
||||
|
|
@ -616,7 +610,7 @@ _MODEL_NAME_SEP_CHARS = ("-", ".", "/", " ")
|
|||
|
||||
|
||||
def _discover_fla_model_types() -> frozenset[str]:
|
||||
"""Model_types in the installed transformers whose modeling file imports `from fla.*`."""
|
||||
"""Installed-transformers model_types whose modeling file imports `from fla.*`."""
|
||||
global _TRANSFORMERS_FLA_MODEL_TYPES_CACHE
|
||||
if _TRANSFORMERS_FLA_MODEL_TYPES_CACHE is not None:
|
||||
return _TRANSFORMERS_FLA_MODEL_TYPES_CACHE
|
||||
|
|
@ -690,17 +684,17 @@ def _torch_has_hip() -> bool:
|
|||
def _rocm_classify_unified_memory(props: Any) -> tuple[str, bool]:
|
||||
"""Classify a ROCm device as unified-memory (APU) or discrete.
|
||||
|
||||
Returns ``(gcn_arch, is_unified)`` where:
|
||||
- ``gcn_arch`` is the canonical arch string (e.g. ``"gfx1151"``) when a
|
||||
known attribute is present, or ``""`` when all arch attrs are absent.
|
||||
- ``is_unified`` is ``True`` for AMD APUs with a shared GPU/system-RAM pool
|
||||
Returns ``(gcn_arch, is_unified)``:
|
||||
- ``gcn_arch``: canonical arch string (e.g. ``"gfx1151"``) when a known
|
||||
attribute is present, else ``""``.
|
||||
- ``is_unified``: ``True`` for AMD APUs with a shared GPU/system-RAM pool
|
||||
(gfx1150 Strix Point, gfx1151 Strix Halo) — these need a lower
|
||||
``set_per_process_memory_fraction`` cap to leave headroom for the OS.
|
||||
``set_per_process_memory_fraction`` cap to leave OS headroom.
|
||||
|
||||
Classification priority:
|
||||
1. ``gcnArchName`` / variant spellings (stable, naming-independent).
|
||||
2. Device-name substring match as a last-resort fallback when all arch
|
||||
attrs are absent (AMD SDK / Radeon wheels may not populate them):
|
||||
2. Device-name substring match (last resort when all arch attrs absent;
|
||||
AMD SDK / Radeon wheels may not populate them):
|
||||
- gfx1150 Strix Point: ``Radeon 890M``, ``Radeon 880M``
|
||||
- gfx1151 Strix Halo: ``Radeon 8060S`` (Ryzen AI MAX+ 395),
|
||||
``Radeon 8050S`` (cut-down SKU)
|
||||
|
|
@ -726,7 +720,7 @@ def _rocm_classify_unified_memory(props: Any) -> tuple[str, bool]:
|
|||
def _tilelang_platform_supported() -> bool:
|
||||
"""True iff a tilelang 0.1.8 wheel will load: Linux x86_64/aarch64, non-HIP torch.
|
||||
|
||||
HIP excluded because tilelang 0.1.8 has no HIP GEMM instruction and crashes mid-backward.
|
||||
HIP excluded: tilelang 0.1.8 has no HIP GEMM and crashes mid-backward.
|
||||
"""
|
||||
import platform as _platform
|
||||
|
||||
|
|
@ -770,9 +764,10 @@ def _run_pip(cmd: list[str], event_queue: Any, label: str) -> bool:
|
|||
def _ensure_tilelang_backend_unconditional(event_queue: Any) -> bool:
|
||||
"""Install pinned tilelang + apache-tvm-ffi; two-step repair if a broken tvm-ffi is present.
|
||||
|
||||
Returns True iff both import post-call. Step 1 surgically downgrades a broken tvm-ffi
|
||||
with --force-reinstall --no-deps so torch / CUDA stay untouched; step 2 is a regular
|
||||
install for missing transitive deps. Bypass via UNSLOTH_STUDIO_SKIP_TILELANG_INSTALL=1.
|
||||
Returns True iff both import post-call. Step 1 downgrades a broken tvm-ffi
|
||||
with --force-reinstall --no-deps so torch / CUDA stay untouched; step 2 is a
|
||||
regular install for missing transitive deps. Bypass via
|
||||
UNSLOTH_STUDIO_SKIP_TILELANG_INSTALL=1.
|
||||
"""
|
||||
if os.getenv(_TILELANG_SKIP_ENV) == "1":
|
||||
return False
|
||||
|
|
@ -800,7 +795,7 @@ def _ensure_tilelang_backend_unconditional(event_queue: Any) -> bool:
|
|||
logger.info("tilelang + apache-tvm-ffi already installed")
|
||||
return True
|
||||
|
||||
# Step 1: --no-deps keeps --force-reinstall from touching torch/CUDA via the dep graph.
|
||||
# Step 1: --no-deps keeps --force-reinstall off torch/CUDA via the dep graph.
|
||||
if needs_repair:
|
||||
logger.info(
|
||||
"Forcing apache-tvm-ffi downgrade: %s is on the broken list",
|
||||
|
|
@ -822,7 +817,7 @@ def _ensure_tilelang_backend_unconditional(event_queue: Any) -> bool:
|
|||
if not _run_pip(repair_cmd, event_queue, "TileLang backend repair"):
|
||||
return False
|
||||
|
||||
# Step 2: regular install pulls in transitive deps (z3-solver, ml-dtypes) without touching torch.
|
||||
# Step 2: regular install pulls transitive deps (z3-solver, ml-dtypes) without touching torch.
|
||||
_send_status(
|
||||
event_queue,
|
||||
f"Installing TileLang=={_TILELANG_PACKAGE_VERSION} for faster training...",
|
||||
|
|
@ -855,9 +850,9 @@ def _ensure_tilelang_backend(event_queue: Any, model_name: str) -> None:
|
|||
|
||||
|
||||
# ── Fast-path hooks ──
|
||||
# Wrap transformers' is_{flash_linear_attention,causal_conv1d}_available so the first call
|
||||
# (at modeling import time) drives the install. Any model that queries the gate gets the
|
||||
# install; models that never query it (Llama, Gemma, dense Qwen) pay nothing.
|
||||
# Wrap transformers' is_{flash_linear_attention,causal_conv1d}_available so the
|
||||
# first call (at modeling import) drives the install. Models that query the gate
|
||||
# get the install; those that never query it (Llama, Gemma, dense Qwen) pay nothing.
|
||||
# UNSLOTH_STUDIO_SKIP_FAST_PATH_HOOKS=1 falls back to the legacy substring path.
|
||||
|
||||
|
||||
|
|
@ -894,8 +889,8 @@ def _install_fast_path_hooks(event_queue: Any, model_name: str) -> None:
|
|||
logger.info("Fast-path hooks disabled via env; using substring fallback")
|
||||
return
|
||||
|
||||
# On HIP torch, even already-installed tilelang crashes FLA's TileLang dispatch.
|
||||
# User can override with FLA_TILELANG=1.
|
||||
# On HIP torch, even installed tilelang crashes FLA's TileLang dispatch.
|
||||
# Override with FLA_TILELANG=1.
|
||||
if _torch_has_hip() and os.environ.get("FLA_TILELANG") is None:
|
||||
os.environ["FLA_TILELANG"] = "0"
|
||||
logger.info(
|
||||
|
|
@ -937,8 +932,8 @@ def _install_fast_path_hooks(event_queue: Any, model_name: str) -> None:
|
|||
logger.warning("%s install raised: %s; falling back to torch", gate_name, exc)
|
||||
ok = False
|
||||
logger.info("%s hook done; available=%s", gate_name, ok)
|
||||
# post_available_fn handles "gate already True but ancillary kernel broken" (e.g. tilelang
|
||||
# missing while FLA imports fine); skip when install_fn already chained the follow-up.
|
||||
# post_available_fn handles "gate already True but ancillary kernel broken"
|
||||
# (e.g. tilelang missing while FLA imports); skip when install_fn already chained it.
|
||||
if ok and not ran_install and post_available_fn is not None:
|
||||
try:
|
||||
post_available_fn(event_queue)
|
||||
|
|
@ -966,7 +961,7 @@ def _install_fast_path_hooks(event_queue: Any, model_name: str) -> None:
|
|||
return True
|
||||
|
||||
def _fla_post_available(eq: Any) -> None:
|
||||
# FLA already imports; repair tilelang if missing or on the broken tvm-ffi list.
|
||||
# FLA imports; repair tilelang if missing or on the broken tvm-ffi list.
|
||||
if not _model_wants_tilelang(model_name):
|
||||
return
|
||||
if _installed_tvm_ffi_version() not in _TVM_FFI_BROKEN_VERSIONS and _tilelang_importable():
|
||||
|
|
@ -1057,8 +1052,8 @@ def _mlx_vlm_max_resized_size(width: int, height: int, target: int) -> tuple[int
|
|||
largest_side = max(width, height)
|
||||
if largest_side <= target:
|
||||
return width, height
|
||||
# Integer formula matches unsloth_zoo's collator (Python round() differs
|
||||
# by 1px on half-pixel cases). max(1, _) avoids zero-side degenerate output.
|
||||
# Integer formula matches unsloth_zoo's collator (Python round() differs by
|
||||
# 1px on half-pixel cases). max(1, _) avoids a zero-side degenerate output.
|
||||
new_w = max(1, (width * target + largest_side // 2) // largest_side)
|
||||
new_h = max(1, (height * target + largest_side // 2) // largest_side)
|
||||
return new_w, new_h
|
||||
|
|
@ -1079,9 +1074,9 @@ def _resize_mlx_vlm_image(image, resize):
|
|||
if new_size != image.size:
|
||||
resampling = getattr(Image, "Resampling", Image).LANCZOS
|
||||
image = image.resize(new_size, resampling)
|
||||
# When a resize is requested, hand mlx-vlm a writable RGB ndarray so its
|
||||
# PIL-path square-resize is skipped and HF processors don't warn on
|
||||
# non-writable views. resize=None (Default) above keeps the original PIL.
|
||||
# On resize, hand mlx-vlm a writable RGB ndarray so its PIL-path
|
||||
# square-resize is skipped and HF processors don't warn on non-writable
|
||||
# views. resize=None above keeps the original PIL.
|
||||
return np.array(image, copy = True)
|
||||
|
||||
|
||||
|
|
@ -1092,12 +1087,12 @@ def _resize_mlx_vlm_images(value, resize):
|
|||
|
||||
|
||||
def _adapt_for_mlx_vlm(items, resize = None):
|
||||
"""Adapt GPU-path VLM dataset output for mlx-vlm consumption.
|
||||
"""Adapt GPU-path VLM dataset output for mlx-vlm.
|
||||
|
||||
The GPU path embeds PIL images inside messages content as
|
||||
{"type": "image", "image": PIL_Image}. mlx-vlm's prepare_inputs
|
||||
needs images at top-level to produce pixel_values — regardless of
|
||||
model type. Extract them and leave bare {"type": "image"} placeholders.
|
||||
The GPU path embeds PIL images in message content as
|
||||
{"type": "image", "image": PIL_Image}, but mlx-vlm's prepare_inputs needs
|
||||
images at top-level to produce pixel_values (any model type). Extract them
|
||||
and leave bare {"type": "image"} placeholders.
|
||||
"""
|
||||
adapted = []
|
||||
for item in items:
|
||||
|
|
@ -1218,8 +1213,8 @@ def _mlx_local_dataset_loader_for_files(files: list[str]) -> str:
|
|||
def _run_mlx_training(event_queue, stop_queue, config):
|
||||
"""Self-contained MLX training path for Apple Silicon.
|
||||
|
||||
Uses MLXTrainer from unsloth_zoo directly -- no torch/SFTTrainer needed.
|
||||
Mirrors the event_queue protocol so the parent process pump works unchanged.
|
||||
Uses unsloth_zoo's MLXTrainer directly (no torch/SFTTrainer). Mirrors the
|
||||
event_queue protocol so the parent process pump works unchanged.
|
||||
"""
|
||||
import time
|
||||
import math
|
||||
|
|
@ -1276,8 +1271,8 @@ def _run_mlx_training(event_queue, stop_queue, config):
|
|||
lr_scheduler_type = _normalize_mlx_studio_scheduler(config.get("lr_scheduler_type", "linear"))
|
||||
|
||||
# ── 1. Load model ──
|
||||
# Force text-only if the dataset is not an image dataset, even if the model
|
||||
# has vision capabilities (e.g. Qwen3.5-VL trained on plain alpaca text).
|
||||
# Force text-only for non-image datasets even on vision-capable models
|
||||
# (e.g. Qwen3.5-VL trained on plain alpaca text).
|
||||
_send("status", status_message = f"Loading {model_name}...")
|
||||
is_dataset_image = bool(config.get("is_dataset_image", False))
|
||||
training_type = config.get("training_type", "LoRA/QLoRA")
|
||||
|
|
@ -1314,8 +1309,8 @@ def _run_mlx_training(event_queue, stop_queue, config):
|
|||
)
|
||||
|
||||
# ── 2. Apply LoRA / full FT ──
|
||||
# Pass gradient_checkpointing as string ("mlx"/"unsloth"/"none"/etc.)
|
||||
# get_peft_model and MLXTrainer both accept strings and handle them.
|
||||
# gradient_checkpointing stays a string ("mlx"/"unsloth"/"none"/etc.);
|
||||
# get_peft_model and MLXTrainer both accept and handle strings.
|
||||
gc_setting = config.get("gradient_checkpointing", "mlx")
|
||||
if isinstance(gc_setting, str):
|
||||
use_grad_checkpoint = (
|
||||
|
|
@ -1418,8 +1413,8 @@ def _run_mlx_training(event_queue, stop_queue, config):
|
|||
eval_dataset = _load_local(config["local_eval_datasets"])
|
||||
|
||||
# ── 3b. Format dataset (VLM or text) ──
|
||||
# Reuse the GPU path's format pipeline for both VLM (auto-detects OCR/caption/
|
||||
# llava/sharegpt+images) and text (alpaca/sharegpt/chatml → "text" column).
|
||||
# Reuse the GPU format pipeline for VLM (auto-detects OCR/caption/llava/
|
||||
# sharegpt+images) and text (alpaca/sharegpt/chatml → "text" column).
|
||||
format_type = config.get("format_type", "")
|
||||
try:
|
||||
from utils.datasets import format_and_template_dataset
|
||||
|
|
@ -1511,7 +1506,7 @@ def _run_mlx_training(event_queue, stop_queue, config):
|
|||
output_dir = config.get("output_dir", "")
|
||||
if not output_dir:
|
||||
output_dir = f"{model_name.replace('/', '_')}_{int(time.time())}"
|
||||
# Resolve to ~/.unsloth/studio/outputs/ so the export page can find it
|
||||
# Resolve to ~/.unsloth/studio/outputs/ so the export page finds it
|
||||
from utils.paths import resolve_output_dir, ensure_dir
|
||||
|
||||
output_dir = str(resolve_output_dir(output_dir))
|
||||
|
|
@ -1525,9 +1520,9 @@ def _run_mlx_training(event_queue, stop_queue, config):
|
|||
else:
|
||||
eval_steps_val = int(eval_steps_val)
|
||||
|
||||
# MLX: per-element clip to [-1, 1]; norm clip disabled (it needs a
|
||||
# global reduction that breaks MLX's eager pipeline). 1.0 (not 5.0):
|
||||
# |g_i| > 5 rarely fires, so the historical 5.0 was effectively no-op.
|
||||
# MLX: per-element clip to [-1, 1]; norm clip disabled (its global reduction
|
||||
# breaks MLX's eager pipeline). 1.0 not 5.0: |g_i| > 5 rarely fires, so the
|
||||
# historical 5.0 was effectively a no-op.
|
||||
max_grad_norm = 0.0
|
||||
max_grad_value = 1.0 # TODO: expose MLX grad-clip in Studio UI for power users
|
||||
|
||||
|
|
@ -1561,7 +1556,7 @@ def _run_mlx_training(event_queue, stop_queue, config):
|
|||
),
|
||||
)
|
||||
|
||||
# Tell the parent that eval is configured so the frontend shows the eval chart
|
||||
# Tell the parent eval is configured so the frontend shows the eval chart
|
||||
if eval_dataset is not None and eval_steps_val > 0:
|
||||
_send("eval_configured")
|
||||
|
||||
|
|
@ -1716,7 +1711,7 @@ def _run_mlx_training(event_queue, stop_queue, config):
|
|||
except _queue.Empty:
|
||||
continue
|
||||
except (EOFError, OSError):
|
||||
# why safe: pipe permanently broken, no further messages can arrive
|
||||
# Safe: pipe permanently broken, no more messages can arrive.
|
||||
return
|
||||
|
||||
stop_thread = threading.Thread(target = _poll_stop, daemon = True)
|
||||
|
|
@ -1753,15 +1748,15 @@ def run_training_process(*, event_queue: Any, stop_queue: Any, config: dict) ->
|
|||
"""Subprocess entrypoint. Fresh Python — no stale module state.
|
||||
|
||||
Args:
|
||||
event_queue: mp.Queue for sending progress/status/error events to parent.
|
||||
stop_queue: mp.Queue for receiving stop commands from parent.
|
||||
config: Training configuration dict with all parameters.
|
||||
event_queue: mp.Queue for progress/status/error events to the parent.
|
||||
stop_queue: mp.Queue for stop commands from the parent.
|
||||
config: Training config dict with all parameters.
|
||||
"""
|
||||
os.environ["TOKENIZERS_PARALLELISM"] = "false"
|
||||
os.environ["PYTHONWARNINGS"] = "ignore" # Suppress warnings at C-level before imports
|
||||
|
||||
# Offline auto-detect: skip ~25s of HF retries per call when DNS is
|
||||
# dead. Scoped to this subprocess (orchestrator spawns a fresh one).
|
||||
# Offline auto-detect: skip ~25s of HF retries per call when DNS is dead.
|
||||
# Scoped to this subprocess (orchestrator spawns a fresh one).
|
||||
if "HF_HUB_OFFLINE" not in os.environ:
|
||||
import socket as _socket
|
||||
import threading as _threading
|
||||
|
|
@ -1807,7 +1802,7 @@ def run_training_process(*, event_queue: Any, stop_queue: Any, config: dict) ->
|
|||
|
||||
# ── 0. MLX FAST-PATH (must run before any torch/transformers imports) ──
|
||||
# Apple Silicon uses MLXTrainer directly -- skip transformers version
|
||||
# activation, causal-conv1d install, and torch imports entirely.
|
||||
# activation, causal-conv1d install, and torch imports.
|
||||
backend_path = str(Path(__file__).resolve().parent.parent.parent)
|
||||
if backend_path not in sys.path:
|
||||
sys.path.insert(0, backend_path)
|
||||
|
|
@ -1827,7 +1822,7 @@ def run_training_process(*, event_queue: Any, stop_queue: Any, config: dict) ->
|
|||
)
|
||||
return
|
||||
# Activate correct transformers version (Gemma-4 needs 5.5.0, etc.)
|
||||
# Must happen before any transformers/mlx-lm imports in _run_mlx_training.
|
||||
# before any transformers/mlx-lm imports in _run_mlx_training.
|
||||
try:
|
||||
_activate_transformers_version(model_name)
|
||||
except Exception:
|
||||
|
|
@ -1860,10 +1855,9 @@ def run_training_process(*, event_queue: Any, stop_queue: Any, config: dict) ->
|
|||
return
|
||||
|
||||
# ── 1a. Auto-enable trust_remote_code for NemotronH/Nano models ──
|
||||
# NemotronH has config parsing bugs in transformers that require
|
||||
# trust_remote_code=True as a workaround. Other transformers 5.x models
|
||||
# (Qwen3.5, Gemma 4, etc.) are native and do NOT need it — enabling it
|
||||
# bypasses the compiler (disabling fused CE).
|
||||
# NemotronH has transformers config-parsing bugs needing trust_remote_code=True
|
||||
# as a workaround. Other transformers 5.x models (Qwen3.5, Gemma 4, etc.) are
|
||||
# native and do NOT need it — enabling it bypasses the compiler (disabling fused CE).
|
||||
# NOTE: Must NOT match Llama-Nemotron (standard Llama architecture).
|
||||
_NEMOTRON_TRUST_SUBSTRINGS = ("nemotron_h", "nemotron-h", "nemotron-3-nano")
|
||||
_lowered = model_name.lower()
|
||||
|
|
@ -1880,19 +1874,17 @@ def run_training_process(*, event_queue: Any, stop_queue: Any, config: dict) ->
|
|||
|
||||
# ── 1b. Install fast-path kernel libraries for the chosen model.
|
||||
#
|
||||
# 1) causal-conv1d ALWAYS runs eagerly via the substring path.
|
||||
# Some SSM modeling files (nemotron_h, falcon_h1, granitemoehybrid)
|
||||
# use `lazy_load_kernel("causal-conv1d")` directly and never call
|
||||
# transformers' `is_causal_conv1d_available()`, so the runtime
|
||||
# hook on that gate would not fire for them.
|
||||
# 2) FLA + tilelang: primary gate is the runtime hook on transformers'
|
||||
# `is_flash_linear_attention_available`. Models whose architecture
|
||||
# queries that gate auto-trigger the install; others never pay.
|
||||
# `_install_fast_path_hooks` also wraps `is_causal_conv1d_available`
|
||||
# as a defence in depth for newer modeling files that do use it.
|
||||
# 1) causal-conv1d ALWAYS runs eagerly via the substring path. Some SSM
|
||||
# modeling files (nemotron_h, falcon_h1, granitemoehybrid) call
|
||||
# `lazy_load_kernel("causal-conv1d")` directly and never call
|
||||
# `is_causal_conv1d_available()`, so the runtime hook wouldn't fire.
|
||||
# 2) FLA + tilelang: primary gate is the runtime hook on
|
||||
# `is_flash_linear_attention_available`. Architectures that query it
|
||||
# auto-trigger the install; others never pay. `_install_fast_path_hooks`
|
||||
# also wraps `is_causal_conv1d_available` as defence in depth.
|
||||
# 3) mamba-ssm + flash-attn keep their existing substring / size gates.
|
||||
# 4) `UNSLOTH_STUDIO_SKIP_FAST_PATH_HOOKS=1` falls back to the
|
||||
# substring path for FLA / tilelang.
|
||||
# 4) `UNSLOTH_STUDIO_SKIP_FAST_PATH_HOOKS=1` falls back to the substring
|
||||
# path for FLA / tilelang.
|
||||
try:
|
||||
_ensure_causal_conv1d_fast_path(event_queue, model_name)
|
||||
if os.getenv(_FAST_PATH_HOOKS_SKIP_ENV) == "1":
|
||||
|
|
@ -1923,12 +1915,11 @@ def run_training_process(*, event_queue: Any, stop_queue: Any, config: dict) ->
|
|||
return
|
||||
|
||||
# ── 1c. Set fork start method so dataset.map() can multiprocess ──
|
||||
# The parent launched us via spawn (clean process), but the compiled
|
||||
# SFTTrainer checks get_start_method() and disables num_proc if not "fork".
|
||||
# Linux only: fork is the default start method and is safe here (no CUDA
|
||||
# context exists yet). macOS defaults to spawn since Python 3.8 because
|
||||
# fork is unsafe with macOS frameworks (Metal/MPS, CoreFoundation) --
|
||||
# do NOT override on macOS. Windows has no fork at all.
|
||||
# Parent launched us via spawn, but the compiled SFTTrainer checks
|
||||
# get_start_method() and disables num_proc if not "fork". Linux only: fork
|
||||
# is the default and safe here (no CUDA context yet). macOS defaults to spawn
|
||||
# since Python 3.8 (fork unsafe with Metal/MPS, CoreFoundation) -- do NOT
|
||||
# override there. Windows has no fork.
|
||||
if sys.platform == "linux":
|
||||
import multiprocessing as _mp
|
||||
try:
|
||||
|
|
@ -1951,15 +1942,15 @@ def run_training_process(*, event_queue: Any, stop_queue: Any, config: dict) ->
|
|||
# ── 1d. Stub torchao on Windows ROCm ──
|
||||
# Shared with the export worker; see core/_torchao_stub.py for the full
|
||||
# rationale (torchao -> torch.distributed._functional_collectives crashes on
|
||||
# Windows ROCm because the RCCL backend is absent). No-op off Windows ROCm.
|
||||
# Must run before any import of transformers / unsloth_zoo.
|
||||
# Windows ROCm: no RCCL backend). No-op off Windows ROCm. Must run before any
|
||||
# import of transformers / unsloth_zoo.
|
||||
from core._torchao_stub import install_torchao_windows_rocm_stub
|
||||
|
||||
install_torchao_windows_rocm_stub()
|
||||
|
||||
# ── 1e. Ensure torch.distributed helper attrs are present ──
|
||||
# Single-GPU training never initialises the process group, so these helpers
|
||||
# are never called — but transformers/trl import them unconditionally.
|
||||
# Single-GPU training never inits the process group, so these helpers are
|
||||
# never called — but transformers/trl import them unconditionally.
|
||||
_td_stubs = {
|
||||
"is_initialized": lambda: False,
|
||||
"is_available": lambda: False,
|
||||
|
|
@ -1989,16 +1980,14 @@ def run_training_process(*, event_queue: Any, stop_queue: Any, config: dict) ->
|
|||
# torch._grouped_mm has a null HIP kernel on gfx1200 (ROCm ≤ 7.12 Windows),
|
||||
# causing 0xC0000005 (access violation) during training.
|
||||
#
|
||||
# Root cause: the JitDecomp autograd decomposition system (NOT torch.compile)
|
||||
# dispatches _grouped_mm → _fused_adagrad_ → _grouped_mm HIP → null crash.
|
||||
# TORCHDYNAMO_DISABLE=1 stops the compiler frontend but does NOT stop
|
||||
# JitDecomp, so we must also override the CUDA dispatch key for _grouped_mm
|
||||
# with a safe Python fallback.
|
||||
# Root cause: JitDecomp autograd decomposition (NOT torch.compile) dispatches
|
||||
# _grouped_mm → _fused_adagrad_ → _grouped_mm HIP → null crash.
|
||||
# TORCHDYNAMO_DISABLE=1 stops the compiler frontend but not JitDecomp, so we
|
||||
# also override the CUDA dispatch key for _grouped_mm with a Python fallback.
|
||||
#
|
||||
# Fixed in AMD's wheel: torch==2.11.0+rocm7.13.0 — the 3-D batch and grouped
|
||||
# (with offs) variants of _grouped_mm now have working HIP kernels on gfx1200.
|
||||
# We gate the dispatch override on HIP < 7.13 so users on the fixed wheel get
|
||||
# the real GPU kernel rather than our Python fallback.
|
||||
# Fixed in torch==2.11.0+rocm7.13.0: the 3-D batch and grouped (with offs)
|
||||
# variants now have working HIP kernels on gfx1200. We gate the override on
|
||||
# HIP < 7.13 so the fixed wheel uses the real GPU kernel.
|
||||
#
|
||||
# Verified: null on torch==2.10.0+rocm7.12.0; fixed on torch==2.11.0+rocm7.13.0.
|
||||
#
|
||||
|
|
@ -2006,17 +1995,15 @@ def run_training_process(*, event_queue: Any, stop_queue: Any, config: dict) ->
|
|||
# Tensor? bias=None, ScalarType? out_dtype=None) -> Tensor
|
||||
# offs: optional group-split offsets (MoE-style variable-size batches)
|
||||
#
|
||||
# torch is already in sys.modules from section 1e's `import torch.distributed`.
|
||||
# Module-level _WINDOWS_ROCM_GROUPED_MM_LIB keeps the registration alive past
|
||||
# function return / mid-run GC.
|
||||
# torch is already in sys.modules from section 1e. Module-level
|
||||
# _WINDOWS_ROCM_GROUPED_MM_LIB keeps the registration alive past return / GC.
|
||||
global _WINDOWS_ROCM_GROUPED_MM_LIB
|
||||
if sys.platform == "win32":
|
||||
_torch_for_rocm = sys.modules.get("torch")
|
||||
# Broad check: torch.version.hip OR "rocm" in torch.__version__.
|
||||
# AMD SDK / Radeon Windows wheels do not always populate
|
||||
# torch.version.hip; without the broad check the BNB version pin,
|
||||
# dynamo-disable, and _grouped_mm fallback below silently skip
|
||||
# (matches the torchao stub gate above and main.py).
|
||||
# Broad check: torch.version.hip OR "rocm" in torch.__version__. AMD SDK /
|
||||
# Radeon Windows wheels don't always populate torch.version.hip; without
|
||||
# the broad check the BNB version pin, dynamo-disable, and _grouped_mm
|
||||
# fallback below silently skip (matches the torchao stub gate and main.py).
|
||||
_build_version_for_rocm = (
|
||||
getattr(_torch_for_rocm, "__version__", "").lower()
|
||||
if _torch_for_rocm is not None
|
||||
|
|
@ -2030,20 +2017,19 @@ def run_training_process(*, event_queue: Any, stop_queue: Any, config: dict) ->
|
|||
)
|
||||
)
|
||||
if _is_win_rocm_torch:
|
||||
# Disable dynamo (belt-and-suspenders; JitDecomp patch below is the
|
||||
# real fix, but keeping dynamo off avoids any other compile paths).
|
||||
# Disable dynamo (belt-and-suspenders; the JitDecomp patch below is
|
||||
# the real fix, but dynamo off avoids any other compile paths).
|
||||
if "TORCHDYNAMO_DISABLE" not in os.environ:
|
||||
os.environ["TORCHDYNAMO_DISABLE"] = "1"
|
||||
logger.info("Windows ROCm: torch.compile (dynamo) disabled")
|
||||
|
||||
# BNB auto-detects the HIP version from torch.version.hip and uses
|
||||
# it to choose which DLL to load (e.g. "7.13" → rocm713.dll).
|
||||
# AMD's Windows BNB prerelease wheel ships only one rocm DLL, and its
|
||||
# version suffix does not always match the torch HIP version (e.g.
|
||||
# torch==2.11.0+rocm7.13.0 ships HIP 7.13, but the BNB wheel still
|
||||
# ships rocm72.dll). We detect the actual DLL name from the installed
|
||||
# package and override BNB's auto-detection. "72" is a safe fallback
|
||||
# if detection fails. Callers may override by pre-setting the var.
|
||||
# BNB auto-detects the HIP version from torch.version.hip to pick a
|
||||
# DLL (e.g. "7.13" → rocm713.dll). AMD's Windows BNB prerelease wheel
|
||||
# ships only one rocm DLL whose version suffix may not match the torch
|
||||
# HIP version (e.g. torch==2.11.0+rocm7.13.0 ships HIP 7.13, but the
|
||||
# BNB wheel still ships rocm72.dll). Detect the actual DLL name from
|
||||
# the installed package and override auto-detection. "72" is a safe
|
||||
# fallback if detection fails. Callers may override by pre-setting the var.
|
||||
if "BNB_ROCM_VERSION" not in os.environ:
|
||||
_bnb_rocm_ver = None
|
||||
try:
|
||||
|
|
@ -2064,10 +2050,9 @@ def run_training_process(*, event_queue: Any, stop_queue: Any, config: dict) ->
|
|||
)
|
||||
if _m:
|
||||
_all_vers.append(_m.group(1))
|
||||
# Pick the highest numeric suffix so that e.g. "713"
|
||||
# wins over "72" when both variants are present.
|
||||
# Filesystem glob order is not guaranteed, so always
|
||||
# sort rather than stopping at the first match.
|
||||
# Pick the highest numeric suffix so "713" wins over
|
||||
# "72" when both are present. Glob order isn't guaranteed,
|
||||
# so always sort rather than take the first match.
|
||||
if _all_vers:
|
||||
_bnb_rocm_ver = max(_all_vers, key = lambda v: int(v))
|
||||
except Exception:
|
||||
|
|
@ -2081,17 +2066,15 @@ def run_training_process(*, event_queue: Any, stop_queue: Any, config: dict) ->
|
|||
_bnb_rocm_ver,
|
||||
)
|
||||
|
||||
# Parse HIP version for the kernel-fix gate below.
|
||||
# torch.version.hip can be "7.13.99004", "7.2.0", etc.
|
||||
# AMD SDK / Radeon wheels may leave torch.version.hip unset and
|
||||
# encode the ROCm version in torch.__version__ instead
|
||||
# (e.g. "2.11.0+rocm7.13.0" or "2.9.0+rocmsdk20251116"); fall back
|
||||
# to that string when version.hip is missing.
|
||||
# Parse HIP version for the kernel-fix gate below. torch.version.hip
|
||||
# can be "7.13.99004", "7.2.0", etc. AMD SDK / Radeon wheels may leave
|
||||
# it unset and encode the ROCm version in torch.__version__ instead
|
||||
# (e.g. "2.11.0+rocm7.13.0" or "2.9.0+rocmsdk20251116"); fall back to
|
||||
# that string when version.hip is missing.
|
||||
def _hip_ver_at_least(major: int, minor: int) -> bool:
|
||||
_hip_str = getattr(getattr(_torch_for_rocm, "version", None), "hip", None)
|
||||
if not _hip_str:
|
||||
# Try the standard "+rocmX.Y.Z" embedded version first
|
||||
# (e.g. "2.11.0+rocm7.13.0").
|
||||
# Try the standard "+rocmX.Y.Z" embedded version first.
|
||||
_ver_match = re.search(r"rocm(\d+)\.(\d+)", _build_version_for_rocm)
|
||||
if _ver_match:
|
||||
return (
|
||||
|
|
@ -2100,12 +2083,11 @@ def run_training_process(*, event_queue: Any, stop_queue: Any, config: dict) ->
|
|||
) >= (major, minor)
|
||||
# AMD SDK / Radeon Windows wheels encode the build as
|
||||
# "+rocmsdk<date>" (e.g. "2.9.0+rocmsdk20251116") with no
|
||||
# explicit rocmX.Y component. The rocmsdk format was
|
||||
# introduced after the gfx120X null-kernel fix landed in
|
||||
# ROCm 7.13, so any wheel with this suffix is new enough to
|
||||
# have working HIP kernels. Treat as >= 7.13 rather than
|
||||
# falling back to False and installing the Python workaround
|
||||
# on a wheel that doesn't need it.
|
||||
# explicit rocmX.Y. The rocmsdk format postdates the gfx120X
|
||||
# null-kernel fix (ROCm 7.13), so any such wheel has working
|
||||
# HIP kernels. Treat as >= 7.13 rather than returning False
|
||||
# and installing the Python workaround on a wheel that
|
||||
# doesn't need it.
|
||||
if "rocmsdk" in _build_version_for_rocm:
|
||||
logger.debug(
|
||||
"Windows ROCm: AMD SDK wheel detected (%r); "
|
||||
|
|
@ -2139,10 +2121,9 @@ def run_training_process(*, event_queue: Any, stop_queue: Any, config: dict) ->
|
|||
)
|
||||
return False
|
||||
|
||||
# _grouped_mm HIP kernel was null on gfx1200 in ROCm ≤ 7.12,
|
||||
# causing 0xC0000005. AMD fixed it in ROCm 7.13 (torch 2.11+).
|
||||
# Only install the Python fallback on the affected versions so users
|
||||
# on 7.13+ get the real GPU kernel for MoE workloads.
|
||||
# _grouped_mm HIP kernel was null on gfx1200 in ROCm ≤ 7.12 (causing
|
||||
# 0xC0000005); fixed in ROCm 7.13 (torch 2.11+). Install the Python
|
||||
# fallback only on affected versions so 7.13+ gets the real GPU kernel.
|
||||
if not _hip_ver_at_least(7, 13):
|
||||
try:
|
||||
import warnings as _warnings
|
||||
|
|
@ -2159,24 +2140,22 @@ def run_training_process(*, event_queue: Any, stop_queue: Any, config: dict) ->
|
|||
"""Python mm/bmm fallback for _grouped_mm on gfx1200 (null HIP kernel, ROCm ≤ 7.12)."""
|
||||
_t = _torch_for_rocm
|
||||
if offs is None:
|
||||
# No offsets: behave like the real op, which
|
||||
# accepts either (M, K) x (K, N) -> mm, or 3-D
|
||||
# batched inputs -> bmm. Picking torch.mm
|
||||
# unconditionally previously raised "self must be
|
||||
# a matrix" on 3-D MoE workloads.
|
||||
# No offsets: match the real op — (M, K) x (K, N) ->
|
||||
# mm, or 3-D batched -> bmm. An unconditional torch.mm
|
||||
# previously raised "self must be a matrix" on 3-D MoE.
|
||||
if self.dim() == 3 and mat2.dim() == 3:
|
||||
result = _t.bmm(self.contiguous(), mat2.contiguous())
|
||||
elif self.dim() == 3 and mat2.dim() == 2:
|
||||
# Broadcast 2-D mat2 across the batch dim.
|
||||
result = _t.matmul(self.contiguous(), mat2.contiguous())
|
||||
elif self.dim() == 2 and mat2.dim() == 3:
|
||||
# Broadcast 2-D self across batch via matmul semantics.
|
||||
# Broadcast 2-D self across batch via matmul.
|
||||
result = _t.matmul(self.contiguous(), mat2.contiguous())
|
||||
else:
|
||||
result = _t.mm(self.contiguous(), mat2.contiguous())
|
||||
else:
|
||||
# Grouped case: offs[i] is the exclusive end-row of
|
||||
# group i in `self`; mat2 may be 3-D or 2-D.
|
||||
# Grouped: offs[i] is the exclusive end-row of group
|
||||
# i in `self`; mat2 may be 3-D or 2-D.
|
||||
offs_list = offs.tolist()
|
||||
pieces = []
|
||||
prev = 0
|
||||
|
|
@ -2189,7 +2168,7 @@ def run_training_process(*, event_queue: Any, stop_queue: Any, config: dict) ->
|
|||
b_part = mat2.contiguous()
|
||||
pieces.append(_t.mm(a_part, b_part))
|
||||
prev = end
|
||||
# Include any trailing rows not covered by offs
|
||||
# Include trailing rows not covered by offs.
|
||||
if prev < self.shape[0]:
|
||||
a_tail = self[prev:].contiguous()
|
||||
b_tail = (
|
||||
|
|
@ -2237,26 +2216,23 @@ def run_training_process(*, event_queue: Any, stop_queue: Any, config: dict) ->
|
|||
)
|
||||
|
||||
# ── 1g. ROCm OOM guard ──
|
||||
# On RDNA 4 (gfx1200/gfx1201) and other ROCm GPUs, exhausting VRAM can
|
||||
# cause a HIP driver hang that freezes the entire system rather than
|
||||
# raising a Python exception. set_per_process_memory_fraction caps the
|
||||
# HIP allocator so PyTorch raises OutOfMemoryError before hitting the
|
||||
# hardware limit, giving the UI a clean error instead of a system freeze.
|
||||
# Only applied on ROCm -- NVIDIA CUDA has a graceful OOM path and does
|
||||
# not need this cap.
|
||||
# On RDNA 4 (gfx1200/gfx1201) and other ROCm GPUs, exhausting VRAM can hang
|
||||
# the HIP driver and freeze the whole system instead of raising. So
|
||||
# set_per_process_memory_fraction caps the HIP allocator and PyTorch raises
|
||||
# OutOfMemoryError before the hardware limit, giving the UI a clean error.
|
||||
# ROCm only -- NVIDIA CUDA has a graceful OOM path.
|
||||
# Unified-memory APUs (gfx1150 Strix Point / gfx1151 Strix Halo) share GPU
|
||||
# and system RAM in one pool: 0.90 of 128 GB starves the OS. Use 0.80 there.
|
||||
# Primary classifier: gcnArchName from device properties — stable within a
|
||||
# product family and naming-independent. AMD SDK / Radeon wheels may omit
|
||||
# gcnArchName or expose it under a variant spelling, so we try several attr
|
||||
# names then fall back to known device-name markers as a last resort.
|
||||
# and system RAM in one pool: 0.90 of 128 GB starves the OS, so use 0.80.
|
||||
# Primary classifier: gcnArchName (stable within a product family,
|
||||
# naming-independent). AMD SDK / Radeon wheels may omit it or use a variant
|
||||
# spelling, so try several attrs then fall back to device-name markers.
|
||||
# Non-fatal: silently skipped if torch is not importable.
|
||||
if _hw.IS_ROCM:
|
||||
try:
|
||||
import torch as _torch_mem
|
||||
if _torch_mem.cuda.is_available():
|
||||
# Classify unified vs discrete via _rocm_classify_unified_memory.
|
||||
# See that function's docstring for classification priority.
|
||||
# Classify unified vs discrete via _rocm_classify_unified_memory
|
||||
# (see its docstring for classification priority).
|
||||
_props = _torch_mem.cuda.get_device_properties(0)
|
||||
_dev_name = _props.name
|
||||
_gcn_arch, _is_unified = _rocm_classify_unified_memory(_props)
|
||||
|
|
@ -2310,9 +2286,9 @@ def run_training_process(*, event_queue: Any, stop_queue: Any, config: dict) ->
|
|||
return
|
||||
|
||||
# ── 2b. EMBEDDING MODEL FAST-PATH ──
|
||||
# Embedding models use a completely different pipeline (FastSentenceTransformer
|
||||
# + SentenceTransformerTrainer + MultipleNegativesRankingLoss) so we branch
|
||||
# early and handle the entire flow in a self-contained function.
|
||||
# Embedding models use a different pipeline (FastSentenceTransformer +
|
||||
# SentenceTransformerTrainer + MultipleNegativesRankingLoss), so branch early
|
||||
# and handle the whole flow in a self-contained function.
|
||||
if config.get("is_embedding", False):
|
||||
try:
|
||||
_run_embedding_training(event_queue, stop_queue, config)
|
||||
|
|
@ -2380,9 +2356,9 @@ def run_training_process(*, event_queue: Any, stop_queue: Any, config: dict) ->
|
|||
stop_thread.start()
|
||||
|
||||
# ── 4. Execute the training pipeline ──
|
||||
# Order: detect → dataset → model → prepare → train
|
||||
# Dataset processing (including LLM-assisted detection) runs BEFORE model
|
||||
# loading so both never occupy VRAM at the same time.
|
||||
# Order: detect → dataset → model → prepare → train. Dataset processing
|
||||
# (incl. LLM-assisted detection) runs BEFORE model loading so both never
|
||||
# occupy VRAM at once.
|
||||
try:
|
||||
hf_token = config.get("hf_token", "")
|
||||
hf_token = hf_token if hf_token and hf_token.strip() else None
|
||||
|
|
@ -2451,8 +2427,8 @@ def run_training_process(*, event_queue: Any, stop_queue: Any, config: dict) ->
|
|||
if eval_steps is not None and float(eval_steps) <= 0:
|
||||
eval_dataset = None
|
||||
|
||||
# Tell the parent process that eval is configured so the frontend
|
||||
# shows "Waiting for first evaluation step..." instead of "not configured"
|
||||
# Tell the parent eval is configured so the frontend shows
|
||||
# "Waiting for first evaluation step..." instead of "not configured".
|
||||
if eval_dataset is not None:
|
||||
event_queue.put(
|
||||
{
|
||||
|
|
@ -2475,7 +2451,7 @@ def run_training_process(*, event_queue: Any, stop_queue: Any, config: dict) ->
|
|||
)
|
||||
return
|
||||
|
||||
# ── Start tqdm monitor early so it captures download + tokenization bars ──
|
||||
# ── Start tqdm monitor early to capture download + tokenization bars ──
|
||||
import threading as _th
|
||||
|
||||
_tqdm_stop = _th.Event()
|
||||
|
|
@ -2533,9 +2509,9 @@ def run_training_process(*, event_queue: Any, stop_queue: Any, config: dict) ->
|
|||
# ── 4d. Prepare model (LoRA, full finetuning, or CPT) ──
|
||||
if is_cpt:
|
||||
_send_status(event_queue, "Configuring LoRA for continued pretraining...")
|
||||
# embed_tokens (if the user included it) goes to modules_to_save —
|
||||
# trained full-precision at embedding_learning_rate. lm_head stays as
|
||||
# a LoRA target for merge compatibility (see unsloth PR #4106).
|
||||
# embed_tokens (if included) goes to modules_to_save — trained
|
||||
# full-precision at embedding_learning_rate. lm_head stays a LoRA
|
||||
# target for merge compatibility (see unsloth PR #4106).
|
||||
_user_modules = config.get("target_modules") or []
|
||||
wants_embed = "embed_tokens" in _user_modules
|
||||
cpt_trains_embeddings = wants_embed
|
||||
|
|
@ -2610,13 +2586,13 @@ def run_training_process(*, event_queue: Any, stop_queue: Any, config: dict) ->
|
|||
)
|
||||
return
|
||||
|
||||
# embedding_learning_rate is validated by the Pydantic model (Optional[float],
|
||||
# gt=0, lt=1.0); if present it is already a finite float in range.
|
||||
# embedding_learning_rate is validated by Pydantic (Optional[float],
|
||||
# gt=0, lt=1.0); if present it's already a finite float in range.
|
||||
embedding_lr_value = config.get("embedding_learning_rate")
|
||||
if is_cpt:
|
||||
if cpt_trains_embeddings:
|
||||
if embedding_lr_value is None:
|
||||
# Default embedding_learning_rate = lr/10 per Unsloth's CPT notebook.
|
||||
# Default embedding_learning_rate = lr/10 (Unsloth CPT notebook).
|
||||
embedding_lr_value = lr_value / 10.0
|
||||
logger.info(
|
||||
f"CPT: using default embedding_learning_rate={embedding_lr_value:.1e} "
|
||||
|
|
@ -2644,7 +2620,7 @@ def run_training_process(*, event_queue: Any, stop_queue: Any, config: dict) ->
|
|||
tensorboard_dir = str(resolve_tensorboard_dir(tensorboard_dir))
|
||||
ensure_dir(Path(tensorboard_dir))
|
||||
|
||||
# Start training (directly — no inner thread, we ARE the subprocess)
|
||||
# Start training directly — no inner thread, we ARE the subprocess.
|
||||
dataset_display = config.get("hf_dataset", "") or config.get("uploaded_file", "") or ""
|
||||
_send_status(
|
||||
event_queue,
|
||||
|
|
@ -2761,10 +2737,8 @@ def _run_embedding_training(event_queue: Any, stop_queue: Any, config: dict) ->
|
|||
"""Self-contained embedding model training pipeline.
|
||||
|
||||
Uses FastSentenceTransformer + SentenceTransformerTrainer +
|
||||
MultipleNegativesRankingLoss — completely separate from the
|
||||
LLM/VLM/audio paths in UnslothTrainer.
|
||||
|
||||
Mirrors the pattern from the reference embedding notebooks:
|
||||
MultipleNegativesRankingLoss — separate from UnslothTrainer's LLM/VLM/audio
|
||||
paths. Mirrors the reference embedding notebooks:
|
||||
All_MiniLM_L6_v2.py, BGE_M3.py, EmbeddingGemma_300M.py,
|
||||
ModernBert.py, Qwen3_Embedding_0_6B.py
|
||||
"""
|
||||
|
|
@ -2860,7 +2834,7 @@ def _run_embedding_training(event_queue: Any, stop_queue: Any, config: dict) ->
|
|||
_send_status(event_queue, "Configuring LoRA adapters (FEATURE_EXTRACTION)...")
|
||||
try:
|
||||
gradient_checkpointing = config.get("gradient_checkpointing", False)
|
||||
# Normalize: "none" or empty → False
|
||||
# Normalize "none"/empty → False.
|
||||
if gradient_checkpointing in ("none", "", None):
|
||||
gradient_checkpointing = False
|
||||
|
||||
|
|
@ -2913,8 +2887,8 @@ def _run_embedding_training(event_queue: Any, stop_queue: Any, config: dict) ->
|
|||
token = hf_token,
|
||||
)
|
||||
elif local_datasets:
|
||||
# Load from local file(s) — mirrors the non-embedding pipeline's
|
||||
# directory handling so recipe outputs (parquet-files/) work.
|
||||
# Load local file(s) — mirrors the non-embedding pipeline's directory
|
||||
# handling so recipe outputs (parquet-files/) work.
|
||||
all_files: list[str] = []
|
||||
for dataset_file in local_datasets:
|
||||
file_path = (
|
||||
|
|
@ -3050,7 +3024,7 @@ def _run_embedding_training(event_queue: Any, stop_queue: Any, config: dict) ->
|
|||
else:
|
||||
training_args_kwargs["num_train_epochs"] = num_epochs if num_epochs > 0 else 2
|
||||
|
||||
# warmup: prefer warmup_ratio (standard for embedding scripts), fallback to steps
|
||||
# warmup: prefer warmup_ratio (standard for embedding scripts), else steps
|
||||
if warmup_ratio is not None and warmup_ratio > 0:
|
||||
training_args_kwargs["warmup_ratio"] = warmup_ratio
|
||||
elif warmup_steps_val is not None and warmup_steps_val > 0:
|
||||
|
|
@ -3074,7 +3048,7 @@ def _run_embedding_training(event_queue: Any, stop_queue: Any, config: dict) ->
|
|||
|
||||
# ── 8. Create progress callback ──
|
||||
class _EmbeddingProgressCallback(TrainerCallback):
|
||||
"""Sends training progress events to the parent process via event_queue."""
|
||||
"""Send training progress events to the parent via event_queue."""
|
||||
|
||||
def on_log(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -1,15 +1,14 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Logging configuration for structured logging with structlog.
|
||||
"""Structured logging configuration via structlog.
|
||||
|
||||
This module provides centralized logging configuration with environment-specific
|
||||
formats and processors. Supports both development and production environments
|
||||
with consistent structured logging.
|
||||
Centralized config with environment-specific formats and processors for
|
||||
development and production.
|
||||
|
||||
Key Features:
|
||||
- Environment-specific formatting (JSON for production, console for development)
|
||||
- Timestamp standardization (ISO format)
|
||||
Key features:
|
||||
- Environment-specific formatting (JSON for prod, console for dev)
|
||||
- ISO timestamps
|
||||
- Context variable integration
|
||||
- Log level filtering
|
||||
- Logger caching for performance
|
||||
|
|
@ -28,8 +27,8 @@ from loggers.handlers import filter_sensitive_data
|
|||
class LogConfig:
|
||||
"""Structured logging configuration for the application.
|
||||
|
||||
Provides static method to configure structlog with environment-specific
|
||||
formatting and processors for consistent structured logging.
|
||||
Static method to configure structlog with environment-specific
|
||||
formatting and processors.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -41,9 +40,8 @@ class LogConfig:
|
|||
service_name: Name of the service for logging identification
|
||||
env: Environment (development/production), affects logging format
|
||||
"""
|
||||
# Determine log level from environment
|
||||
# Log level from environment; fall back to INFO if invalid.
|
||||
log_level_name = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||
# Fallback to INFO if an invalid level is provided
|
||||
log_level = getattr(logging, log_level_name, logging.INFO)
|
||||
|
||||
if sys.platform == "win32":
|
||||
|
|
@ -56,13 +54,13 @@ class LogConfig:
|
|||
|
||||
structlog.configure(
|
||||
processors = [
|
||||
# Reorder processors to control field order
|
||||
# Ordered to control output field order.
|
||||
structlog.processors.TimeStamper(fmt = "iso"), # timestamp first
|
||||
structlog.processors.add_log_level, # level second
|
||||
structlog.contextvars.merge_contextvars,
|
||||
structlog.processors.format_exc_info,
|
||||
filter_sensitive_data,
|
||||
# Custom processor to flatten the extra field
|
||||
# Flatten the extra field into the main dict.
|
||||
lambda logger, method_name, event_dict: {
|
||||
"timestamp": event_dict.get("timestamp"),
|
||||
"level": event_dict.get("level"),
|
||||
|
|
|
|||
|
|
@ -3,16 +3,16 @@
|
|||
|
||||
"""Logging handlers and middleware for structured logging.
|
||||
|
||||
This module provides FastAPI middleware and structlog processors for:
|
||||
FastAPI middleware + structlog processors for:
|
||||
- Request/response logging with timing
|
||||
- Sensitive data filtering in logs
|
||||
- Structured logging configuration
|
||||
- Error handling with detailed context
|
||||
- Sensitive data filtering
|
||||
- Structured logging config
|
||||
- Error handling with context
|
||||
|
||||
Key Components:
|
||||
- LoggingMiddleware: FastAPI middleware for request/response logging
|
||||
- filter_sensitive_data: Structlog processor for data sanitization
|
||||
- get_logger: Factory function for structured loggers
|
||||
Key components:
|
||||
- LoggingMiddleware: request/response logging
|
||||
- filter_sensitive_data: structlog processor for sanitization
|
||||
- get_logger: factory for structured loggers
|
||||
"""
|
||||
|
||||
import re
|
||||
|
|
@ -38,7 +38,6 @@ class LoggingMiddleware(BaseHTTPMiddleware):
|
|||
try:
|
||||
response = await call_next(request)
|
||||
|
||||
# Log response
|
||||
process_time = (time.time() - start_time) * 1000
|
||||
|
||||
EXCLUDED_PATHS = {
|
||||
|
|
@ -108,10 +107,8 @@ def filter_sensitive_data(logger, method_name, event_dict):
|
|||
|
||||
|
||||
def get_logger(name: str) -> structlog.BoundLogger:
|
||||
"""Get a logger instance for a specific module.
|
||||
"""Get a bound structured logger for a module.
|
||||
Args:
|
||||
name: Usually __name__ of the module
|
||||
Returns:
|
||||
A bound structured logger
|
||||
"""
|
||||
return structlog.get_logger(name)
|
||||
|
|
|
|||
|
|
@ -9,20 +9,20 @@ import os
|
|||
import sys
|
||||
from pathlib import Path as _Path
|
||||
|
||||
# Suppress annoying C-level dependency warnings globally
|
||||
# Suppress C-level dependency warnings globally
|
||||
os.environ["PYTHONWARNINGS"] = "ignore"
|
||||
|
||||
# ── Windows AMD ROCm DLL injection ──────────────────────────────────────────
|
||||
# Python 3.8+ ignores PATH for extension modules; register ROCm bin dirs with
|
||||
# os.add_dll_directory() so amdhip64.dll etc. are found before any torch import.
|
||||
if sys.platform == "win32":
|
||||
# Retained at module scope -- os.add_dll_directory returns a handle that
|
||||
# Retained at module scope; os.add_dll_directory returns a handle that
|
||||
# removes the search-path entry when garbage collected.
|
||||
_ROCM_DLL_HANDLES: list = []
|
||||
|
||||
def _add_rocm_dll_dirs() -> None:
|
||||
candidates = []
|
||||
# 1. HIP_PATH / ROCM_PATH -- set by the AMD HIP SDK installer
|
||||
# 1. HIP_PATH / ROCM_PATH set by the AMD HIP SDK installer
|
||||
for _var in ("HIP_PATH", "ROCM_PATH"):
|
||||
_val = os.environ.get(_var)
|
||||
if _val:
|
||||
|
|
@ -34,7 +34,7 @@ if sys.platform == "win32":
|
|||
)
|
||||
|
||||
def _ver_key(name: str) -> tuple:
|
||||
# Numeric tuple key so "10.0" sorts after "7.0"; non-numeric chunks fall back to string.
|
||||
# Numeric tuple key so "10.0" sorts after "7.0"; non-numeric chunks fall back to string
|
||||
parts = []
|
||||
for chunk in name.split("."):
|
||||
try:
|
||||
|
|
@ -62,16 +62,15 @@ if sys.platform == "win32":
|
|||
del _add_rocm_dll_dirs
|
||||
|
||||
# ── Windows AMD ROCm: set BNB_ROCM_VERSION before any bitsandbytes import ─
|
||||
# bitsandbytes on Windows ROCm tries to load libbitsandbytes_rocm<ver>.dll
|
||||
# where <ver> comes from torch.version.hip (e.g. "7.13..." → "713").
|
||||
# The installed BNB wheel ships rocm72.dll (not rocm713.dll), so without
|
||||
# this the server process crashes with "Configured ROCm binary not found".
|
||||
# Detect the available DLL, fall back to "72", and set BNB_ROCM_VERSION
|
||||
# before any import that pulls in bitsandbytes (mirrors worker.py logic).
|
||||
# Gate on the rocm bnb DLL (the exact file this configures) or HIP_PATH/
|
||||
# ROCM_PATH, not on torch.version.hip: that needed importing torch on every
|
||||
# Windows host (NVIDIA/CPU included), adding seconds to startup. Radeon
|
||||
# wheels without HIP_PATH still ship the rocm bnb DLL, so they are covered.
|
||||
# bitsandbytes on Windows ROCm loads libbitsandbytes_rocm<ver>.dll where
|
||||
# <ver> comes from torch.version.hip (e.g. "7.13..." → "713"). The installed
|
||||
# BNB wheel ships rocm72.dll (not rocm713.dll), so without this the server
|
||||
# crashes with "Configured ROCm binary not found". Detect the available DLL,
|
||||
# fall back to "72", and set BNB_ROCM_VERSION before any bitsandbytes import
|
||||
# (mirrors worker.py). Gate on the rocm bnb DLL (the file this configures) or
|
||||
# HIP_PATH/ROCM_PATH, not torch.version.hip: that needed importing torch on
|
||||
# every Windows host (NVIDIA/CPU too), adding startup seconds. Radeon wheels
|
||||
# without HIP_PATH still ship the rocm bnb DLL, so they're covered.
|
||||
if "BNB_ROCM_VERSION" not in os.environ:
|
||||
import glob as _glob
|
||||
import logging as _logging
|
||||
|
|
@ -82,7 +81,7 @@ if sys.platform == "win32":
|
|||
try:
|
||||
import importlib.util as _ilu
|
||||
_bnb_spec = _ilu.find_spec("bitsandbytes")
|
||||
# submodule_search_locations (not spec.origin) handles editable installs.
|
||||
# submodule_search_locations (not spec.origin) handles editable installs
|
||||
if _bnb_spec and _bnb_spec.submodule_search_locations:
|
||||
import re as _re_bnb
|
||||
|
||||
|
|
@ -102,7 +101,7 @@ if sys.platform == "win32":
|
|||
"Windows ROCm: BNB DLL detection failed (%s); falling back to version '72'",
|
||||
_e,
|
||||
)
|
||||
# rocm bnb DLL present, or HIP_PATH/ROCM_PATH set (DLL unparsable -> "72").
|
||||
# rocm bnb DLL present, or HIP_PATH/ROCM_PATH set (DLL unparsable -> "72")
|
||||
if _found_rocm_bnb or _hip_env:
|
||||
_bnb_rocm_ver_final = _bnb_rocm_ver or "72"
|
||||
os.environ["BNB_ROCM_VERSION"] = _bnb_rocm_ver_final
|
||||
|
|
@ -111,8 +110,8 @@ if sys.platform == "win32":
|
|||
_bnb_rocm_ver_final,
|
||||
)
|
||||
|
||||
# Ensure backend dir is on sys.path so _platform_compat is importable when
|
||||
# main.py is launched directly (e.g. `uvicorn main:app`).
|
||||
# Put backend dir on sys.path so _platform_compat is importable when main.py
|
||||
# is launched directly (e.g. `uvicorn main:app`).
|
||||
_backend_dir = str(_Path(__file__).parent)
|
||||
if _backend_dir not in sys.path:
|
||||
sys.path.insert(0, _backend_dir)
|
||||
|
|
@ -126,14 +125,14 @@ except ValueError as exc:
|
|||
_raw = os.environ.get("UNSLOTH_CPU_THREADS")
|
||||
raise SystemExit(f"Error: Invalid UNSLOTH_CPU_THREADS value {_raw!r}: {exc}") from None
|
||||
|
||||
# Fix for Anaconda/conda-forge Python: seed platform._sys_version_cache before
|
||||
# any library imports that trigger attrs -> rich -> structlog -> platform crash.
|
||||
# Anaconda/conda-forge Python: seed platform._sys_version_cache before any
|
||||
# library import triggers attrs -> rich -> structlog -> platform crash.
|
||||
# See: https://github.com/python/cpython/issues/102396
|
||||
import _platform_compat # noqa: F401
|
||||
|
||||
# Direct `uvicorn main:app` launches bypass run.py, so re-export here too
|
||||
# (mirrors run.py). Required BEFORE the unsloth-zoo import below, since
|
||||
# its LLAMA_CPP_DEFAULT_DIR binding is import-time.
|
||||
# (mirrors run.py). Required BEFORE the unsloth-zoo import below, whose
|
||||
# LLAMA_CPP_DEFAULT_DIR binding is import-time.
|
||||
from utils.paths.storage_roots import studio_root as _studio_root
|
||||
|
||||
try:
|
||||
|
|
@ -166,15 +165,13 @@ _STUDIO_INSTALL_ID_RE = _re.compile(r"^[0-9a-f]{64}$")
|
|||
|
||||
def _read_studio_install_id() -> str:
|
||||
"""Per-install opaque id written by install.sh / install.ps1 at
|
||||
$STUDIO_HOME/share/studio_install_id. Returns "" when the file is
|
||||
absent (pre-PR install, fresh tree never run through the installer)
|
||||
or contains anything other than a 64-char lowercase-hex token --
|
||||
in which case /api/health emits "" and the launcher's _check_health
|
||||
falls back to the existing "no baked id, accept any healthy
|
||||
Unsloth backend" path. This intentionally replaces a previous
|
||||
sha256(resolved_install_path) so the field carries no install-path
|
||||
information for callers reaching /api/health (relevant when Studio
|
||||
is run with -H 0.0.0.0)."""
|
||||
$STUDIO_HOME/share/studio_install_id. Returns "" when the file is absent
|
||||
(pre-PR install, or fresh tree never run through the installer) or holds
|
||||
anything other than a 64-char lowercase-hex token; then /api/health emits
|
||||
"" and the launcher's _check_health falls back to "no baked id, accept any
|
||||
healthy Unsloth backend". Replaces a previous sha256(resolved_install_path)
|
||||
so the field carries no install-path info for callers reaching /api/health
|
||||
(relevant when Studio runs with -H 0.0.0.0)."""
|
||||
try:
|
||||
token = (_STUDIO_ROOT_RESOLVED / "share" / "studio_install_id").read_text().strip()
|
||||
except (OSError, ValueError):
|
||||
|
|
@ -186,29 +183,28 @@ _STUDIO_ROOT_ID_CACHE: str = _read_studio_install_id()
|
|||
|
||||
|
||||
def _studio_root_id() -> str:
|
||||
"""Same-install discriminator for /api/health: a per-install opaque
|
||||
token written once by the installer and read once at module import.
|
||||
Empty when no installer-written token is present; the launcher
|
||||
contract treats "" as "no baked id, accept any healthy backend"."""
|
||||
"""Same-install discriminator for /api/health: a per-install opaque token
|
||||
written once by the installer and read once at module import. Empty when no
|
||||
installer token is present; the launcher contract treats "" as "no baked id,
|
||||
accept any healthy backend"."""
|
||||
return _STUDIO_ROOT_ID_CACHE
|
||||
|
||||
|
||||
# Fix broken Windows registry MIME types. Some Windows installs map .js to
|
||||
# "text/plain" in the registry (HKCR\.js\Content Type). Python's mimetypes
|
||||
# module reads from the registry, and FastAPI/Starlette's StaticFiles uses
|
||||
# mimetypes.guess_type() to set Content-Type headers. Browsers enforce strict
|
||||
# MIME checking for ES module scripts (<script type="module">) and will refuse
|
||||
# to execute .js files served as text/plain — resulting in a blank page.
|
||||
# Calling add_type() *before* StaticFiles is instantiated ensures the correct
|
||||
# types are used regardless of the OS registry.
|
||||
# Fix broken Windows registry MIME types. Some Windows installs map .js to
|
||||
# "text/plain" in the registry (HKCR\.js\Content Type). Python's mimetypes reads
|
||||
# from the registry, and Starlette's StaticFiles uses mimetypes.guess_type() for
|
||||
# Content-Type. Browsers strictly MIME-check ES module scripts
|
||||
# (<script type="module">) and refuse .js served as text/plain, giving a blank
|
||||
# page. Calling add_type() *before* StaticFiles is instantiated forces the
|
||||
# correct types regardless of the OS registry.
|
||||
if sys.platform == "win32":
|
||||
mimetypes.add_type("application/javascript", ".js")
|
||||
mimetypes.add_type("text/css", ".css")
|
||||
|
||||
# Suppress annoying dependency warnings in production
|
||||
# Suppress dependency warnings in production
|
||||
if os.getenv("ENVIRONMENT_TYPE", "production") == "production":
|
||||
warnings.filterwarnings("ignore")
|
||||
# Alternatively, you can be more specific:
|
||||
# Or be more specific:
|
||||
# warnings.filterwarnings("ignore", category=DeprecationWarning)
|
||||
# warnings.filterwarnings("ignore", module="triton.*")
|
||||
|
||||
|
|
@ -300,7 +296,7 @@ def _desktop_owner() -> dict[str, str] | None:
|
|||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Startup: detect hardware, seed default admin if needed. Shutdown: clean up compiled cache."""
|
||||
# Clean up any stale compiled cache from previous runs
|
||||
# Clean up stale compiled cache from previous runs
|
||||
clear_unsloth_compiled_cache()
|
||||
|
||||
# Remove stale .venv_overlay from previous versions — no longer used.
|
||||
|
|
@ -354,8 +350,8 @@ async def lifespan(app: FastAPI):
|
|||
import structlog
|
||||
structlog.get_logger(__name__).warning("cleanup_orphaned_runs failed at startup: %s", exc)
|
||||
|
||||
# Pre-cache the helper GGUF model for LLM-assisted dataset detection.
|
||||
# Runs in a background thread so it doesn't block server startup.
|
||||
# Pre-cache the helper GGUF model for LLM-assisted dataset detection,
|
||||
# in a background thread so it doesn't block server startup.
|
||||
import threading
|
||||
|
||||
def _precache():
|
||||
|
|
@ -412,7 +408,7 @@ app.add_middleware(LoggingMiddleware)
|
|||
|
||||
|
||||
# Citation favicons load from www.google.com/s2/favicons; *.gstatic.com is
|
||||
# kept for legacy web-search faviconV2 paths. Everything else is same-origin.
|
||||
# kept for legacy web-search faviconV2 paths. All else is same-origin.
|
||||
from starlette.middleware.base import BaseHTTPMiddleware # noqa: E402
|
||||
from starlette.requests import Request as _StarletteRequest # noqa: E402
|
||||
|
||||
|
|
@ -421,7 +417,7 @@ _CSP_SCRIPT_NONCE_HEADER = "x-internal-script-nonce"
|
|||
_ARTIFACT_PREVIEW_FRAME_PATH = "/api/inference/artifact-preview-frame"
|
||||
|
||||
|
||||
# /content is Colab's working directory — more reliable than env vars which
|
||||
# /content is Colab's working directory — more reliable than env vars, which
|
||||
# aren't always set depending on Colab runtime version.
|
||||
import importlib.util as _importlib_util
|
||||
|
||||
|
|
@ -437,19 +433,18 @@ def _build_csp(script_nonce: "str | None" = None) -> str:
|
|||
if script_nonce:
|
||||
script_src += f" 'nonce-{script_nonce}'"
|
||||
# In Colab the parent frame can be colab.research.google.com, a multi-level
|
||||
# *.prod.colab.dev subdomain (e.g. foo.region.prod.colab.dev — note: CSP
|
||||
# wildcards only match one level, so *.prod.colab.dev misses these), or a
|
||||
# sandboxed null-origin output iframe. Use '*' so any ancestor is allowed;
|
||||
# Colab is already a sandboxed single-user environment.
|
||||
# *.prod.colab.dev subdomain (e.g. foo.region.prod.colab.dev — CSP wildcards
|
||||
# only match one level, so *.prod.colab.dev misses these), or a sandboxed
|
||||
# null-origin output iframe. Use '*' to allow any ancestor; Colab is already
|
||||
# a sandboxed single-user environment.
|
||||
frame_ancestors = "*" if _IS_COLAB else "'none'"
|
||||
|
||||
# In Colab the frontend is served over the Colab reverse-proxy at an HTTPS
|
||||
# *.prod.colab.dev URL. Colab's kernel communication layer and the output
|
||||
# iframe scaffolding inject scripts from *.prod.colab.dev and
|
||||
# *.googleusercontent.com, and make fetch/WebSocket connections to those
|
||||
# same origins. Widen script-src and connect-src in Colab mode so those
|
||||
# requests are not blocked. 'unsafe-inline' for scripts is still omitted;
|
||||
# our own inline script uses a nonce.
|
||||
# *.prod.colab.dev URL. Colab's kernel layer and output-iframe scaffolding
|
||||
# inject scripts from *.prod.colab.dev and *.googleusercontent.com, and
|
||||
# fetch/WebSocket to those origins. Widen script-src and connect-src in
|
||||
# Colab mode so those aren't blocked. 'unsafe-inline' for scripts is still
|
||||
# omitted; our inline script uses a nonce.
|
||||
if _IS_COLAB:
|
||||
script_src += " https://*.prod.colab.dev https://*.googleusercontent.com"
|
||||
connect_src = (
|
||||
|
|
@ -482,7 +477,7 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
|||
|
||||
async def dispatch(self, request: _StarletteRequest, call_next):
|
||||
response = await call_next(request)
|
||||
# Strip the internal nonce hand-off header so it never reaches the client.
|
||||
# Strip the internal nonce hand-off header so it never reaches the client
|
||||
nonce = response.headers.get(_CSP_SCRIPT_NONCE_HEADER)
|
||||
if nonce is not None:
|
||||
del response.headers[_CSP_SCRIPT_NONCE_HEADER]
|
||||
|
|
@ -505,7 +500,7 @@ app.add_middleware(SecurityHeadersMiddleware)
|
|||
|
||||
|
||||
# Cap request bodies on protected POSTs. Upload routes get explicit multipart
|
||||
# headroom, while non-upload routes keep the default body cap.
|
||||
# headroom; non-upload routes keep the default body cap.
|
||||
import json as _json_for_413 # noqa: E402
|
||||
from utils.upload_limits import ( # noqa: E402
|
||||
UNSTRUCTURED_RECIPE_UPLOAD_MAX_BYTES,
|
||||
|
|
@ -651,7 +646,7 @@ class MaxBodyMiddleware:
|
|||
if mtype == "http.disconnect":
|
||||
return
|
||||
if mtype != "http.request":
|
||||
# Mid-stream unexpected frame: forwarding would corrupt downstream.
|
||||
# Mid-stream unexpected frame: forwarding would corrupt downstream
|
||||
return
|
||||
body = msg.get("body", b"") or b""
|
||||
if body:
|
||||
|
|
@ -730,13 +725,13 @@ app.include_router(training_router, prefix = "/api/train", tags = ["training"])
|
|||
app.include_router(models_router, prefix = "/api/models", tags = ["models"])
|
||||
app.include_router(chat_history_router, prefix = "/api/chat", tags = ["chat"])
|
||||
app.include_router(inference_router, prefix = "/api/inference", tags = ["inference"])
|
||||
# Studio-only inference endpoints (cancel, etc.) are intentionally NOT
|
||||
# exposed on the /v1 OpenAI-compat prefix below.
|
||||
# Studio-only inference endpoints (cancel, etc.) are NOT exposed on the /v1
|
||||
# OpenAI-compat prefix below.
|
||||
app.include_router(inference_studio_router, prefix = "/api/inference", tags = ["inference"])
|
||||
|
||||
# OpenAI-compatible endpoints: mount the same inference router at /v1
|
||||
# so external tools (Open WebUI, SillyTavern, etc.) can use the
|
||||
# standard /v1/chat/completions path.
|
||||
# OpenAI-compatible endpoints: mount the same inference router at /v1 so
|
||||
# external tools (Open WebUI, SillyTavern, etc.) can use the standard
|
||||
# /v1/chat/completions path.
|
||||
app.include_router(inference_router, prefix = "/v1", tags = ["openai-compat"])
|
||||
app.include_router(providers_router, prefix = "/api/providers", tags = ["providers"])
|
||||
app.include_router(settings_router, prefix = "/api/settings", tags = ["settings"])
|
||||
|
|
@ -757,9 +752,9 @@ async def health_check(request: Request):
|
|||
Unauthenticated callers (Tauri watchdog, frontend bootstrap polls) need
|
||||
``service`` / ``studio_root_id`` / ``chat_only`` / ``desktop_*`` / ``native_path_leases_supported``
|
||||
to (a) re-adopt a sibling backend across restarts and (b) gate UI surfaces
|
||||
before any token is available. None of those leak install path or version.
|
||||
``version`` / ``studio_version`` / ``device_type`` still require a bearer
|
||||
because they fingerprint the host.
|
||||
before a token is available. None of those leak install path or version.
|
||||
``version`` / ``studio_version`` / ``device_type`` require a bearer since
|
||||
they fingerprint the host.
|
||||
"""
|
||||
base = {
|
||||
"status": "healthy",
|
||||
|
|
@ -783,7 +778,7 @@ async def health_check(request: Request):
|
|||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
|
||||
creds = HTTPAuthorizationCredentials(scheme = "Bearer", credentials = auth.split(" ", 1)[1])
|
||||
# Must await: a bare coroutine is truthy and would skip the auth check.
|
||||
# Must await: a bare coroutine is truthy and would skip the auth check
|
||||
subject = await _gcs(creds)
|
||||
except HTTPException:
|
||||
return base
|
||||
|
|
@ -819,7 +814,7 @@ async def shutdown_server(request: Request, current_subject: str = Depends(get_c
|
|||
"""Gracefully shut down the Unsloth Studio server.
|
||||
|
||||
Called by the frontend quit dialog so users can stop the server from the UI
|
||||
without needing to use the CLI or kill the process manually.
|
||||
without the CLI or killing the process manually.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
|
|
@ -842,11 +837,11 @@ async def shutdown_server(request: Request, current_subject: str = Depends(get_c
|
|||
async def get_system_info(current_subject: str = Depends(get_current_subject)):
|
||||
"""Get system information.
|
||||
|
||||
Gated behind auth: the response includes platform, Python version,
|
||||
GPU name, memory total, and ML package set -- enough to fingerprint
|
||||
a host. Studio's chat-only-mode design assumes only the local user
|
||||
reaches /api/system; in -H 0.0.0.0 / Colab / Tauri-relayed setups
|
||||
that assumption breaks unless we require a bearer.
|
||||
Gated behind auth: the response includes platform, Python version, GPU name,
|
||||
memory total, and ML package set -- enough to fingerprint a host. Studio's
|
||||
chat-only design assumes only the local user reaches /api/system; in
|
||||
-H 0.0.0.0 / Colab / Tauri-relayed setups that breaks unless we require a
|
||||
bearer.
|
||||
"""
|
||||
import platform
|
||||
import psutil
|
||||
|
|
@ -865,9 +860,8 @@ async def get_system_info(current_subject: str = Depends(get_current_subject)):
|
|||
return {
|
||||
"platform": platform.platform(),
|
||||
"python_version": platform.python_version(),
|
||||
# Use the centralized _backend_label helper so the /api/system
|
||||
# endpoint reports "rocm" on AMD hosts instead of "cuda", matching
|
||||
# the /api/hardware and /api/gpu-visibility endpoints.
|
||||
# Centralized _backend_label so /api/system reports "rocm" on AMD hosts
|
||||
# instead of "cuda", matching /api/hardware and /api/gpu-visibility.
|
||||
"device_backend": _backend_label(get_device()),
|
||||
"cpu_count": psutil.cpu_count(),
|
||||
"memory": {
|
||||
|
|
@ -888,8 +882,8 @@ async def get_gpu_visibility(current_subject: str = Depends(get_current_subject)
|
|||
async def get_hardware_info(current_subject: str = Depends(get_current_subject)):
|
||||
"""Return GPU name, total VRAM, and key ML package versions.
|
||||
|
||||
Gated behind auth alongside /api/system -- same fingerprinting
|
||||
concern. /api/system/gpu-visibility is also auth-gated already.
|
||||
Gated behind auth alongside /api/system -- same fingerprinting concern.
|
||||
/api/system/gpu-visibility is also auth-gated.
|
||||
"""
|
||||
from utils.hardware import get_gpu_summary, get_package_versions
|
||||
return {
|
||||
|
|
@ -904,11 +898,10 @@ async def get_hardware_info(current_subject: str = Depends(get_current_subject))
|
|||
def _strip_crossorigin(html_bytes: bytes) -> bytes:
|
||||
"""Remove ``crossorigin`` attributes from script/link tags.
|
||||
|
||||
Vite adds ``crossorigin`` by default which forces CORS mode on font
|
||||
subresource loads. When Studio is served over plain HTTP, Firefox
|
||||
HTTPS-Only Mode does not exempt CORS font requests -- causing all
|
||||
@font-face downloads to fail silently. Stripping the attribute
|
||||
makes them regular same-origin fetches that work on any protocol.
|
||||
Vite adds ``crossorigin`` by default, forcing CORS mode on font subresource
|
||||
loads. Over plain HTTP, Firefox HTTPS-Only Mode doesn't exempt CORS font
|
||||
requests, so all @font-face downloads fail silently. Stripping the attribute
|
||||
makes them same-origin fetches that work on any protocol.
|
||||
"""
|
||||
html = html_bytes.decode("utf-8")
|
||||
html = _re.sub(r'\s+crossorigin(?:="[^"]*")?', "", html)
|
||||
|
|
@ -917,8 +910,8 @@ def _strip_crossorigin(html_bytes: bytes) -> bytes:
|
|||
|
||||
def _inject_bootstrap(html_bytes: bytes, app: FastAPI):
|
||||
"""Inject bootstrap credentials when password change is pending.
|
||||
Returns ``(html_bytes, script_nonce_or_None)``; callers forward the
|
||||
nonce via ``_CSP_SCRIPT_NONCE_HEADER`` so CSP allows the inline script.
|
||||
Returns ``(html_bytes, script_nonce_or_None)``; callers forward the nonce
|
||||
via ``_CSP_SCRIPT_NONCE_HEADER`` so CSP allows the inline script.
|
||||
"""
|
||||
import json as _json
|
||||
import secrets as _secrets
|
||||
|
|
@ -949,9 +942,9 @@ _DEFAULT_PORTS = {"http": 80, "https": 443, "ws": 80, "wss": 443}
|
|||
def _canonical_origin(scheme: str, netloc: str) -> Optional[tuple[str, str, int]]:
|
||||
"""Canonicalise an Origin to ``(scheme, host, port)`` for equality.
|
||||
Browsers strip default ports (RFC 6454 sec 6.1) and scheme/host are
|
||||
case-insensitive (RFC 3986), so bare string compare misclassifies
|
||||
same-origin requests as cross-origin. Returns ``None`` on unparseable
|
||||
input so callers fall to the safer cross-origin default.
|
||||
case-insensitive (RFC 3986), so a bare string compare misclassifies
|
||||
same-origin requests as cross-origin. Returns ``None`` on unparseable input
|
||||
so callers fall to the safer cross-origin default.
|
||||
"""
|
||||
scheme = (scheme or "").strip().lower()
|
||||
if not scheme or not netloc:
|
||||
|
|
@ -960,7 +953,7 @@ def _canonical_origin(scheme: str, netloc: str) -> Optional[tuple[str, str, int]
|
|||
if "@" in netloc:
|
||||
netloc = netloc.rsplit("@", 1)[1]
|
||||
# IPv6 hosts use brackets (RFC 3986 sec 3.2.2): ``[::1]:8902``. Bare
|
||||
# ``partition(":")`` mis-parses these and breaks ``unsloth studio -H ::1``.
|
||||
# ``partition(":")`` mis-parses these, breaking ``unsloth studio -H ::1``.
|
||||
if netloc.startswith("["):
|
||||
close = netloc.find("]")
|
||||
if close == -1:
|
||||
|
|
@ -993,7 +986,7 @@ def _is_same_origin_request(request: Request) -> bool:
|
|||
Top-level same-document GETs omit Origin, so missing counts as same-origin.
|
||||
Callers must also emit ``Vary: Origin``. Both sides are canonicalised via
|
||||
:func:`_canonical_origin` so default-port stripping and scheme/host case
|
||||
do not misclassify same-origin requests as cross-origin.
|
||||
don't misclassify same-origin requests as cross-origin.
|
||||
"""
|
||||
origin = request.headers.get("origin")
|
||||
if origin is None:
|
||||
|
|
@ -1064,7 +1057,7 @@ def setup_frontend(app: FastAPI, build_path: Path):
|
|||
|
||||
file_path = (build_path / full_path).resolve()
|
||||
|
||||
# Block path traversal — ensure resolved path stays inside build_path
|
||||
# Block path traversal — resolved path must stay inside build_path
|
||||
if not file_path.is_relative_to(build_path.resolve()):
|
||||
return Response(status_code = 403)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""
|
||||
Pydantic models for API request/response schemas
|
||||
"""
|
||||
"""Pydantic models for API request/response schemas."""
|
||||
|
||||
from .training import (
|
||||
TrainingStartRequest,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""
|
||||
Pydantic schemas for Authentication API
|
||||
"""
|
||||
"""Pydantic schemas for the Authentication API."""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""
|
||||
Pydantic schemas for Data Recipe (DataDesigner) API.
|
||||
"""
|
||||
"""Pydantic schemas for Data Recipe (DataDesigner) API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""
|
||||
Dataset-related Pydantic models for API requests and responses.
|
||||
"""
|
||||
"""Dataset Pydantic models for API requests and responses."""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""
|
||||
Pydantic schemas for Export API.
|
||||
"""
|
||||
"""Pydantic schemas for Export API."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""
|
||||
Pydantic schemas for Inference API
|
||||
"""
|
||||
"""Pydantic schemas for the Inference API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -108,11 +106,8 @@ class UnloadRequest(BaseModel):
|
|||
|
||||
|
||||
class ValidateModelRequest(BaseModel):
|
||||
"""
|
||||
Lightweight validation request to check whether a model identifier
|
||||
*can be resolved* into a ModelConfig.
|
||||
|
||||
This does NOT actually load weights into GPU memory.
|
||||
"""Lightweight check whether a model identifier *can be resolved* into a
|
||||
ModelConfig. Does NOT load weights into GPU memory.
|
||||
"""
|
||||
|
||||
model_path: str = Field(..., description = "Model identifier or local path")
|
||||
|
|
@ -126,8 +121,7 @@ class ValidateModelRequest(BaseModel):
|
|||
|
||||
|
||||
class ValidateModelResponse(BaseModel):
|
||||
"""
|
||||
Result of model validation.
|
||||
"""Result of model validation.
|
||||
|
||||
valid == True means ModelConfig.from_identifier() succeeded and basic
|
||||
introspection (GGUF / LoRA / vision flags) is available.
|
||||
|
|
@ -245,10 +239,9 @@ class UnloadResponse(BaseModel):
|
|||
class LoadProgressResponse(BaseModel):
|
||||
"""Progress of the active GGUF load, sampled on demand.
|
||||
|
||||
Used by the UI to show a real progress bar during the
|
||||
post-download warmup window (mmap + CUDA upload), rather than a
|
||||
generic "Starting model..." spinner that freezes for minutes on
|
||||
large MoE models.
|
||||
Drives a real progress bar during the post-download warmup window
|
||||
(mmap + CUDA upload), instead of a generic "Starting model..." spinner
|
||||
that freezes for minutes on large MoE models.
|
||||
"""
|
||||
|
||||
phase: Optional[str] = Field(
|
||||
|
|
@ -409,10 +402,10 @@ class InputDocumentContentPart(BaseModel):
|
|||
Studio-normalised shape. The frontend sends either
|
||||
``{type:"input_document", file_data:"data:application/pdf;base64,..."}``
|
||||
or ``{type:"input_document", file_url:"https://..."}``, plus optional
|
||||
``filename`` and ``media_type``. ``external_provider`` translates this
|
||||
onto Anthropic's ``document`` block or OpenAI Responses' ``input_file``
|
||||
block for vision-capable providers; non-vision providers drop the
|
||||
part entirely (handled in ``_build_external_messages``).
|
||||
``filename`` and ``media_type``. ``external_provider`` maps this onto
|
||||
Anthropic's ``document`` block or OpenAI Responses' ``input_file`` block
|
||||
for vision-capable providers; non-vision providers drop the part
|
||||
(handled in ``_build_external_messages``).
|
||||
"""
|
||||
|
||||
type: Literal["input_document"]
|
||||
|
|
@ -437,10 +430,10 @@ class InputDocumentContentPart(BaseModel):
|
|||
class OpenAIReasoningContentPart(BaseModel):
|
||||
"""OpenAI Responses reasoning item paired with a tool output.
|
||||
|
||||
Reasoning models can require the previous ``reasoning`` output item
|
||||
to be replayed immediately before an ``image_generation_call`` id
|
||||
when manually managing Responses context. This part is OpenAI-only;
|
||||
routes strip it for every other provider before proxying.
|
||||
Reasoning models can require the previous ``reasoning`` output item to be
|
||||
replayed immediately before an ``image_generation_call`` id when manually
|
||||
managing Responses context. OpenAI-only; routes strip it for every other
|
||||
provider before proxying.
|
||||
"""
|
||||
|
||||
type: Literal["reasoning"]
|
||||
|
|
@ -452,12 +445,12 @@ class OpenAIReasoningContentPart(BaseModel):
|
|||
class ImageGenerationCallContentPart(BaseModel):
|
||||
"""OpenAI Responses image_generation call reference.
|
||||
|
||||
OpenAI accepts prior ``image_generation_call`` items in the next
|
||||
Responses ``input`` array so follow-up prompts can edit or refine a
|
||||
generated image without resending the base64 payload. The frontend
|
||||
forwards this as a synthetic assistant content part when building
|
||||
the next OpenAI Responses request; ``external_provider`` translates
|
||||
it back to the provider-specific top-level input item.
|
||||
OpenAI accepts prior ``image_generation_call`` items in the next Responses
|
||||
``input`` array so follow-up prompts can edit or refine a generated image
|
||||
without resending the base64 payload. The frontend forwards this as a
|
||||
synthetic assistant content part when building the next request;
|
||||
``external_provider`` maps it back to the provider-specific top-level
|
||||
input item.
|
||||
"""
|
||||
|
||||
type: Literal["image_generation_call"]
|
||||
|
|
@ -472,14 +465,13 @@ class CompactionContentPart(BaseModel):
|
|||
"""Anthropic server-side compaction state, attached to an assistant
|
||||
message for round-tripping on the next turn.
|
||||
|
||||
When Anthropic runs compaction during a request, the response
|
||||
carries a ``{"type": "compaction", "content": "<summary>"}`` block
|
||||
on the assistant message. The chat-adapter persists it onto the
|
||||
stored message; the next turn's outbound request must forward it
|
||||
back so Anthropic recognises the existing compaction state and
|
||||
doesn't re-summarise the conversation from scratch. See
|
||||
``external_provider._stream_anthropic`` for the wire-side handling
|
||||
and https://platform.claude.com/docs/en/build-with-claude/compaction
|
||||
When Anthropic compacts during a request, the response carries a
|
||||
``{"type": "compaction", "content": "<summary>"}`` block on the assistant
|
||||
message. The chat-adapter persists it; the next turn's outbound request
|
||||
must forward it back so Anthropic recognises the existing compaction state
|
||||
and doesn't re-summarise from scratch. See
|
||||
``external_provider._stream_anthropic`` for the wire-side handling and
|
||||
https://platform.claude.com/docs/en/build-with-claude/compaction
|
||||
for the upstream contract.
|
||||
"""
|
||||
|
||||
|
|
@ -516,9 +508,9 @@ ContentPart = Annotated[
|
|||
class ChatMessage(BaseModel):
|
||||
"""Single message in a chat conversation.
|
||||
|
||||
``content`` is a string or a list of multimodal content parts. Assistant
|
||||
messages with only ``tool_calls`` populated may set ``content=None``.
|
||||
Missing ``tool_call_id`` on ``role="tool"`` is resolved at the
|
||||
``content`` is a string or list of multimodal content parts. Assistant
|
||||
messages with only ``tool_calls`` may set ``content=None``. Missing
|
||||
``tool_call_id`` on ``role="tool"`` is resolved at the
|
||||
``ChatCompletionRequest`` layer by walking back to the preceding assistant.
|
||||
"""
|
||||
|
||||
|
|
@ -571,16 +563,15 @@ class ChatMessage(BaseModel):
|
|||
|
||||
|
||||
class ChatCompletionRequest(BaseModel):
|
||||
"""
|
||||
OpenAI-compatible chat completion request.
|
||||
"""OpenAI-compatible chat completion request.
|
||||
|
||||
Extensions (non-OpenAI fields) are marked with 'x-unsloth'.
|
||||
Non-OpenAI extension fields are marked with 'x-unsloth'.
|
||||
"""
|
||||
|
||||
# Accept unknown fields defensively so future OpenAI fields (seed,
|
||||
# response_format, logprobs, frequency_penalty, etc.) don't get
|
||||
# silently dropped by Pydantic before route code runs. Mirrors
|
||||
# AnthropicMessagesRequest and ResponsesRequest.
|
||||
# Accept unknown fields so future OpenAI fields (seed, response_format,
|
||||
# logprobs, frequency_penalty, etc.) aren't silently dropped by Pydantic
|
||||
# before route code runs. Mirrors AnthropicMessagesRequest and
|
||||
# ResponsesRequest.
|
||||
model_config = {"extra": "allow"}
|
||||
|
||||
model: str = Field(
|
||||
|
|
@ -738,18 +729,16 @@ class ChatCompletionRequest(BaseModel):
|
|||
@field_validator("enable_prompt_caching", mode = "before")
|
||||
@classmethod
|
||||
def _coerce_enable_prompt_caching(cls, value: Any) -> Any:
|
||||
"""Preserve the pre-PR coercion: the field used to be Optional[bool],
|
||||
so callers historically sent JSON strings `"true"` / `"false"` and
|
||||
Pydantic v1 coerced them. Widening to Optional[Union[bool, str]] for
|
||||
Gemini cache resource names lets `"false"` slip through as a truthy
|
||||
string. Coerce the canonical bool literals back so explicit opt-outs
|
||||
stay opt-out."""
|
||||
"""Preserve the pre-PR coercion. The field was once Optional[bool], so
|
||||
callers historically sent JSON strings `"true"` / `"false"` that
|
||||
Pydantic v1 coerced. Widening to Optional[Union[bool, str]] for Gemini
|
||||
cache resource names lets `"false"` slip through as truthy; coerce the
|
||||
canonical bool literals back so explicit opt-outs stay opt-out."""
|
||||
if isinstance(value, str):
|
||||
lowered = value.strip().lower()
|
||||
# Match Pydantic v1's BooleanField coercion table (yes/y/on/t/1
|
||||
# and no/n/off/f/0) so opt-outs that used to parse still parse.
|
||||
# Anything else is preserved as a string for Gemini's
|
||||
# cachedContent resource path.
|
||||
# and no/n/off/f/0) so old opt-outs still parse. Anything else
|
||||
# stays a string for Gemini's cachedContent resource path.
|
||||
if lowered in ("true", "t", "1", "yes", "y", "on"):
|
||||
return True
|
||||
if lowered in ("false", "f", "0", "no", "n", "off"):
|
||||
|
|
@ -836,10 +825,10 @@ class ChatCompletionRequest(BaseModel):
|
|||
|
||||
OpenAI / Anthropic passthrough require the result id to match the
|
||||
assistant's tool_calls[].id. Prefer function.name match, else first
|
||||
unconsumed tool_call; synth random id only if no candidate exists.
|
||||
Crossing a user turn breaks the lookup.
|
||||
unconsumed tool_call; synth a random id only if none exists. A user
|
||||
turn breaks the lookup.
|
||||
"""
|
||||
# Pre-mark explicit ids first so a sibling missing-id result does not
|
||||
# Pre-mark explicit ids first so a sibling missing-id result doesn't
|
||||
# steal one already claimed by name.
|
||||
consumed: set[tuple[int, int]] = set()
|
||||
|
||||
|
|
@ -902,12 +891,10 @@ class ChatCompletionRequest(BaseModel):
|
|||
|
||||
|
||||
class OpenAIContainerRequest(BaseModel):
|
||||
"""
|
||||
Shared body for the three OpenAI container endpoints (list / create
|
||||
/ delete). Carries the encrypted API key + base URL so the route
|
||||
handler can decrypt it and proxy to the user's OpenAI account.
|
||||
Same pattern as the inference proxy endpoints — keeps the key off
|
||||
persistent storage on the backend.
|
||||
"""Shared body for the three OpenAI container endpoints (list / create /
|
||||
delete). Carries the encrypted API key + base URL so the route handler
|
||||
can decrypt it and proxy to the user's OpenAI account. Same pattern as the
|
||||
inference proxy endpoints — keeps the key off backend persistent storage.
|
||||
"""
|
||||
|
||||
encrypted_api_key: str = Field(
|
||||
|
|
@ -1055,10 +1042,10 @@ class ResponsesOutputTextPart(BaseModel):
|
|||
"""Assistant ``output_text`` content part replayed on subsequent turns.
|
||||
|
||||
When a client (OpenAI Codex CLI, OpenAI Python SDK agents) loops on a
|
||||
stateless Responses endpoint, prior assistant messages are round-tripped
|
||||
as ``{"role":"assistant","content":[{"type":"output_text","text":...,
|
||||
"annotations":[],"logprobs":[]}]}``. We preserve the text and ignore
|
||||
the annotations/logprobs metadata when flattening into Chat Completions.
|
||||
stateless Responses endpoint, prior assistant messages round-trip as
|
||||
``{"role":"assistant","content":[{"type":"output_text","text":...,
|
||||
"annotations":[],"logprobs":[]}]}``. We keep the text and ignore the
|
||||
annotations/logprobs metadata when flattening into Chat Completions.
|
||||
"""
|
||||
|
||||
type: Literal["output_text"]
|
||||
|
|
@ -1073,8 +1060,8 @@ class ResponsesUnknownContentPart(BaseModel):
|
|||
"""Catch-all for content-part types we don't model explicitly.
|
||||
|
||||
Keeps validation green when a client sends newer part types (e.g.
|
||||
``input_audio``, ``input_file``) we haven't mapped; these are silently
|
||||
skipped during normalisation rather than rejected with a 422.
|
||||
``input_audio``, ``input_file``) we haven't mapped; these are skipped
|
||||
during normalisation rather than rejected with a 422.
|
||||
"""
|
||||
|
||||
type: str
|
||||
|
|
@ -1099,16 +1086,15 @@ class ResponsesInputMessage(BaseModel):
|
|||
|
||||
# Codex (gpt-5.3-codex+) attaches a `phase` field ("commentary" |
|
||||
# "final_answer") to assistant messages and requires clients to preserve
|
||||
# it on subsequent turns. We accept and round-trip it; llama-server does
|
||||
# not care about it.
|
||||
# it across turns. We accept and round-trip it; llama-server ignores it.
|
||||
model_config = {"extra": "allow"}
|
||||
|
||||
|
||||
class ResponsesFunctionCallInputItem(BaseModel):
|
||||
"""A prior assistant function_call being replayed in a multi-turn Responses input.
|
||||
"""A prior assistant function_call replayed in a multi-turn Responses input.
|
||||
|
||||
The Responses API represents tool calls as top-level input items (not
|
||||
nested inside assistant messages), correlated across turns by ``call_id``.
|
||||
nested in assistant messages), correlated across turns by ``call_id``.
|
||||
"""
|
||||
|
||||
type: Literal["function_call"]
|
||||
|
|
@ -1125,7 +1111,7 @@ class ResponsesFunctionCallInputItem(BaseModel):
|
|||
class ResponsesFunctionCallOutputInputItem(BaseModel):
|
||||
"""A tool result supplied by the client for a prior function_call.
|
||||
|
||||
Replaces Chat Completions' ``role="tool"`` message. Correlated to the
|
||||
Replaces Chat Completions' ``role="tool"`` message. Correlated to its
|
||||
originating call by ``call_id``.
|
||||
"""
|
||||
|
||||
|
|
@ -1142,10 +1128,9 @@ class ResponsesUnknownInputItem(BaseModel):
|
|||
"""Catch-all for Responses input item types we don't model explicitly.
|
||||
|
||||
Covers ``reasoning`` items (replayed from prior o-series / gpt-5 turns)
|
||||
and any future item types the client may send. These items are dropped
|
||||
during normalisation — llama-server-backed GGUFs cannot consume them —
|
||||
but keeping them in the request-model union stops unrelated turns from
|
||||
failing validation with a 422.
|
||||
and any future item types. Dropped during normalisation (llama-server
|
||||
GGUFs can't consume them), but keeping them in the request-model union
|
||||
stops unrelated turns from failing validation with a 422.
|
||||
"""
|
||||
|
||||
type: str
|
||||
|
|
@ -1156,12 +1141,11 @@ class ResponsesUnknownInputItem(BaseModel):
|
|||
def _responses_input_item_discriminator(v: Any) -> str:
|
||||
"""Route a Responses input item to the correct tagged variant.
|
||||
|
||||
Pydantic's default smart-union matching fails when one variant in the
|
||||
union is tagged with a strict ``Literal`` (``function_call`` /
|
||||
``function_call_output``) and the incoming dict uses a different
|
||||
``type`` — the other variants' validation errors are hidden and the
|
||||
outer ``Union[str, list[...]]`` reports a misleading "Input should be a
|
||||
valid string" error. An explicit discriminator makes the routing
|
||||
Pydantic's smart-union matching fails when one union variant is tagged
|
||||
with a strict ``Literal`` (``function_call`` / ``function_call_output``)
|
||||
and the incoming dict has a different ``type``: other variants' errors are
|
||||
hidden and the outer ``Union[str, list[...]]`` reports a misleading "Input
|
||||
should be a valid string". An explicit discriminator makes routing
|
||||
deterministic and lets us fall through to the catch-all.
|
||||
"""
|
||||
if isinstance(v, dict):
|
||||
|
|
@ -1191,12 +1175,12 @@ ResponsesInputItem = Annotated[
|
|||
|
||||
|
||||
class ResponsesFunctionTool(BaseModel):
|
||||
"""Flat function-tool definition used by the Responses API request.
|
||||
"""Flat function-tool definition for the Responses API request.
|
||||
|
||||
Unlike Chat Completions (which nests ``{"name": ..., "parameters": ...}``
|
||||
inside a ``"function"`` key), the Responses API uses a flat shape with
|
||||
``type``, ``name``, ``description``, ``parameters``, and ``strict`` at the
|
||||
top level of each tool entry.
|
||||
under a ``"function"`` key), the Responses API uses a flat shape with
|
||||
``type``, ``name``, ``description``, ``parameters``, ``strict`` at the top
|
||||
level of each tool entry.
|
||||
"""
|
||||
|
||||
type: Literal["function"]
|
||||
|
|
@ -1220,11 +1204,11 @@ class ResponsesRequest(BaseModel):
|
|||
max_output_tokens: Optional[int] = Field(None, ge = 1)
|
||||
stream: bool = Field(False, description = "Whether to stream the response via SSE")
|
||||
|
||||
# OpenAI function-calling fields — forwarded to llama-server via the
|
||||
# Chat Completions pass-through (see routes/inference.py). Typed as a
|
||||
# plain list so built-in tool shapes (``web_search``, ``file_search``,
|
||||
# ``mcp``, ...) round-trip without validation errors — the translator
|
||||
# picks out only ``type=="function"`` entries for forwarding.
|
||||
# OpenAI function-calling fields, forwarded to llama-server via the Chat
|
||||
# Completions pass-through (see routes/inference.py). Plain list so
|
||||
# built-in tool shapes (``web_search``, ``file_search``, ``mcp``, ...)
|
||||
# round-trip without validation errors; the translator forwards only
|
||||
# ``type=="function"`` entries.
|
||||
tools: Optional[list[dict]] = Field(
|
||||
None,
|
||||
description = (
|
||||
|
|
@ -1280,10 +1264,9 @@ class ResponsesOutputMessage(BaseModel):
|
|||
class ResponsesOutputFunctionCall(BaseModel):
|
||||
"""A function-call output item in the Responses API response.
|
||||
|
||||
Unlike Chat Completions (which nests tool calls inside the assistant
|
||||
message), the Responses API emits each tool call as its own top-level
|
||||
``output`` item so clients can correlate results via ``call_id`` on the
|
||||
next turn.
|
||||
Unlike Chat Completions (tool calls nested in the assistant message), the
|
||||
Responses API emits each tool call as its own top-level ``output`` item so
|
||||
clients can correlate results via ``call_id`` on the next turn.
|
||||
"""
|
||||
|
||||
type: Literal["function_call"] = "function_call"
|
||||
|
|
@ -1439,7 +1422,7 @@ class AnthropicMessagesRequest(BaseModel):
|
|||
top_k: Optional[int] = None
|
||||
stop_sequences: Optional[list[str]] = None
|
||||
metadata: Optional[dict] = None
|
||||
# [x-unsloth] extensions — mirror the OpenAI endpoint convenience fields
|
||||
# [x-unsloth] extensions mirroring the OpenAI endpoint convenience fields
|
||||
min_p: Optional[float] = Field(
|
||||
None, ge = 0.0, le = 1.0, description = "[x-unsloth] Min-p sampling threshold"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""
|
||||
Pydantic schemas for Model Management API
|
||||
"""
|
||||
"""Pydantic schemas for Model Management API"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List, Dict, Any, Literal
|
||||
|
|
@ -56,7 +54,7 @@ class CheckpointListResponse(BaseModel):
|
|||
|
||||
|
||||
class ModelDetails(BaseModel):
|
||||
"""Detailed model configuration and metadata - can be used for both list and detail views"""
|
||||
"""Model configuration and metadata; used for both list and detail views"""
|
||||
|
||||
id: str = Field(..., description = "Model identifier")
|
||||
model_name: Optional[str] = Field(
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""
|
||||
Pydantic schemas for the external LLM providers API.
|
||||
"""
|
||||
"""Pydantic schemas for the external LLM providers API."""
|
||||
|
||||
from typing import Literal, Optional
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""
|
||||
Pydantic response schemas for endpoints that previously returned raw dicts.
|
||||
These are small response models for training and model management routes.
|
||||
"""
|
||||
"""Pydantic response models for training and model management routes
|
||||
(previously returned as raw dicts)."""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ _MAX_LR_VALUE = 1.0
|
|||
_MAX_LORA_R = 16_384
|
||||
_MAX_LORA_ALPHA = 32_768
|
||||
_MIN_VISION_IMAGE_SIZE = 256
|
||||
# 2048 was the most I could get most llms to work at without getting unstable
|
||||
# 2048 is the highest most llms stay stable at
|
||||
_MAX_VISION_IMAGE_SIZE = 2048
|
||||
|
||||
|
||||
|
|
@ -343,7 +343,7 @@ class TrainingStartRequest(BaseModel):
|
|||
@model_validator(mode = "after")
|
||||
def _check_steps_or_epochs(self) -> "TrainingStartRequest":
|
||||
# num_epochs and max_steps each accept 0 as a "use the other one"
|
||||
# sentinel. If both resolve to 0 there's nothing to train against.
|
||||
# sentinel. Both 0 means nothing to train against.
|
||||
if (self.max_steps is None or self.max_steps == 0) and self.num_epochs == 0:
|
||||
raise ValueError("Either num_epochs or max_steps must be > 0; both cannot be 0.")
|
||||
return self
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Pydantic models for authentication tokens.
|
||||
|
||||
This module defines the Token response model used by auth routes.
|
||||
"""
|
||||
"""Pydantic models for authentication tokens."""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
|
|
|||
|
|
@ -3,5 +3,5 @@
|
|||
|
||||
# Intentionally empty. Data-designer loads submodules lazily via qualified names
|
||||
# (impl_qualified_name / config_qualified_name in plugin.py), so importing this
|
||||
# package must NOT touch modules that depend on data_designer.engine.* during
|
||||
# package must NOT touch modules depending on data_designer.engine.* during
|
||||
# Studio's bootstrap (circular import).
|
||||
|
|
|
|||
|
|
@ -17,13 +17,12 @@ from .scraper import ScrapeConfig, materialize_to_jsonl
|
|||
|
||||
|
||||
# In-process cache mapping a stable config signature to the JSONL materialization
|
||||
# path. A single recipe job invokes the seed reader multiple times (validation,
|
||||
# preview, per-column sampling), and the default flow re-scrapes the repo on
|
||||
# every call: for a 2-repo preview that is ~15s of redundant GitHub GraphQL
|
||||
# traffic before any generation fires. Memoize the materialization so the second
|
||||
# and third passes reuse the file the first pass wrote. Cache key excludes the
|
||||
# raw token and uses a short SHA-256 digest so token values never hit memory
|
||||
# twice and token rotation invalidates cleanly.
|
||||
# path. A recipe job calls the seed reader multiple times (validation, preview,
|
||||
# per-column sampling) and the default flow re-scrapes on every call: a 2-repo
|
||||
# preview is ~15s of redundant GitHub GraphQL traffic before any generation.
|
||||
# Memoize so later passes reuse the first pass's file. The key excludes the raw
|
||||
# token and uses a short SHA-256 digest, so token values never hit memory twice
|
||||
# and rotation invalidates cleanly.
|
||||
_SCRAPE_CACHE: dict[tuple, str] = {}
|
||||
_SCRAPE_CACHE_LOCK = threading.Lock()
|
||||
|
||||
|
|
@ -47,8 +46,8 @@ def _lookup_cached_scrape(key: tuple) -> Optional[str]:
|
|||
path = _SCRAPE_CACHE.get(key)
|
||||
if path and Path(path).exists():
|
||||
return path
|
||||
# Stale entry (tmp cleanup, user restarted, ...); drop it so the caller
|
||||
# materializes a fresh file rather than returning a dangling path.
|
||||
# Stale entry (tmp cleanup, restart, ...); drop it so the caller
|
||||
# materializes a fresh file instead of returning a dangling path.
|
||||
if path:
|
||||
with _SCRAPE_CACHE_LOCK:
|
||||
_SCRAPE_CACHE.pop(key, None)
|
||||
|
|
|
|||
|
|
@ -3,11 +3,10 @@
|
|||
|
||||
"""Multi-repo GitHub scraper for the Studio seed plugin.
|
||||
|
||||
Drives the GraphQL-based scraper in `scraper_impl/` per repo. Each repo is
|
||||
scraped with a trial_limits cap so we stop at `limit` items per resource.
|
||||
After scraping, we read the per-resource JSONL shards and flatten them into
|
||||
a single unified JSONL with stable columns (`item_type`, `repo`, `number`,
|
||||
`title`, `body`, ...).
|
||||
Drives the GraphQL scraper in `scraper_impl/` per repo, capped via trial_limits
|
||||
to stop at `limit` items per resource. Then reads the per-resource JSONL shards
|
||||
and flattens them into one unified JSONL with stable columns (`item_type`,
|
||||
`repo`, `number`, `title`, `body`, ...).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -166,7 +165,7 @@ def scrape(cfg: ScrapeConfig, base_dir: Path):
|
|||
client = GitHubClient(token = token.value, token_source = token.source)
|
||||
base_dir.mkdir(parents = True, exist_ok = True)
|
||||
|
||||
# Per-resource trial limits. limit <= 0 means "all": use a very large cap.
|
||||
# Per-resource trial limits. limit <= 0 means "all": use a large cap.
|
||||
effective_limit = cfg.limit if cfg.limit and cfg.limit > 0 else 1_000_000
|
||||
trial_limits: dict[str, int] = {}
|
||||
if "issues" in cfg.item_types:
|
||||
|
|
|
|||
|
|
@ -110,12 +110,11 @@ class GitHubClient:
|
|||
)
|
||||
|
||||
def _is_auth_failure(self, r: "requests.Response") -> bool:
|
||||
"""Distinguish auth failures from rate limiting on 401/403 responses.
|
||||
"""Tell auth failures apart from rate limiting on 401/403.
|
||||
|
||||
- 401: always an auth failure (invalid / expired / wrong-scope token).
|
||||
- 403: an auth failure UNLESS the response carries a clear rate-limit signal
|
||||
(Retry-After header, X-RateLimit-Remaining: 0, or GitHub's secondary /
|
||||
abuse rate-limit response text).
|
||||
- 403: auth failure UNLESS it carries a rate-limit signal (Retry-After,
|
||||
X-RateLimit-Remaining: 0, or secondary / abuse rate-limit text).
|
||||
"""
|
||||
if r.status_code == 401:
|
||||
return True
|
||||
|
|
@ -169,7 +168,7 @@ class GitHubClient:
|
|||
timeout = 120,
|
||||
)
|
||||
self.calls_graphql += 1
|
||||
# Update rate info from response headers
|
||||
# Update rate info from headers
|
||||
rem = r.headers.get("X-RateLimit-Remaining")
|
||||
rst = r.headers.get("X-RateLimit-Reset")
|
||||
if rem is not None:
|
||||
|
|
@ -190,7 +189,7 @@ class GitHubClient:
|
|||
if self._is_auth_failure(r):
|
||||
self._raise_auth_error(r, "GraphQL")
|
||||
if r.status_code == 403 or r.status_code == 429:
|
||||
# Check for secondary/abuse
|
||||
# Secondary/abuse rate limit
|
||||
retry_after = _retry_after_seconds(r.headers.get("Retry-After"))
|
||||
if retry_after is not None:
|
||||
log.warning("Secondary rate limit. Sleep %ds.", retry_after)
|
||||
|
|
@ -204,15 +203,14 @@ class GitHubClient:
|
|||
r.raise_for_status()
|
||||
data = r.json()
|
||||
if "errors" in data and data["errors"]:
|
||||
# Surface errors but allow partial data
|
||||
# Surface errors but allow partial data; retry on RATE_LIMITED
|
||||
errs = data["errors"]
|
||||
# Retry on RATE_LIMITED
|
||||
for e in errs:
|
||||
if e.get("type") == "RATE_LIMITED":
|
||||
self._sleep_until((self.graphql_reset or int(time.time()) + 60))
|
||||
break
|
||||
else:
|
||||
# No rate-limit error, log and return partial
|
||||
# No rate-limit error: log and return partial
|
||||
log.warning("GraphQL errors: %s", json.dumps(errs)[:400])
|
||||
return data
|
||||
continue
|
||||
|
|
@ -268,7 +266,7 @@ class GitHubClient:
|
|||
log.warning("Secondary rate limit on REST. Sleep %ds.", retry_after)
|
||||
time.sleep(retry_after + 2)
|
||||
continue
|
||||
# Check if primary rate
|
||||
# Primary rate limit
|
||||
if self.rest_remaining == 0 and self.rest_reset:
|
||||
self._sleep_until(self.rest_reset)
|
||||
continue
|
||||
|
|
@ -299,11 +297,11 @@ class GitHubClient:
|
|||
return
|
||||
items = r.json()
|
||||
if isinstance(items, dict):
|
||||
# Some endpoints return dict with list field
|
||||
# Some endpoints wrap the list in an "items" field
|
||||
items = items.get("items", [])
|
||||
for it in items:
|
||||
yield it
|
||||
# Follow link header
|
||||
# Follow Link header
|
||||
link = r.headers.get("Link", "")
|
||||
nxt = None
|
||||
for part in link.split(","):
|
||||
|
|
|
|||
|
|
@ -3,11 +3,11 @@
|
|||
|
||||
"""GraphQL queries for GitHub data scraping.
|
||||
|
||||
GitHub's GraphQL rejects queries that define unused fragments, so each query
|
||||
only includes the fragments it actually references.
|
||||
GitHub's GraphQL rejects queries with unused fragments, so each query includes
|
||||
only the fragments it references.
|
||||
"""
|
||||
|
||||
# ---- Fragments (kept as raw strings, composed per query) ----
|
||||
# ---- Fragments (raw strings, composed per query) ----
|
||||
F_ACTOR = """
|
||||
fragment ActorFields on Actor {
|
||||
__typename
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Main scraper orchestration. Collects issues, PRs, discussions, commits, releases, etc.
|
||||
"""Scraper orchestration: issues, PRs, discussions, commits, releases, etc.
|
||||
|
||||
Resumable via state file. Writes JSONL shards under data/{repo}/{resource}.jsonl.
|
||||
"""
|
||||
|
|
@ -49,9 +49,9 @@ class RepoScraper:
|
|||
self.base_dir = base_dir
|
||||
self.client = client
|
||||
self.trial_limits = trial_limits or {}
|
||||
# When light=True, use trimmed GraphQL queries (no reviewThreads,
|
||||
# reviews, commits, timelineItems, files) so PR pages can be much
|
||||
# larger without blowing GitHub's node-count ceiling.
|
||||
# light=True uses trimmed GraphQL queries (no reviewThreads,
|
||||
# reviews, commits, timelineItems, files) so PR pages can be larger
|
||||
# without hitting GitHub's node-count ceiling.
|
||||
self.light = light
|
||||
self.repo_dir = base_dir / f"{owner}__{name}"
|
||||
self.repo_dir.mkdir(parents = True, exist_ok = True)
|
||||
|
|
@ -225,8 +225,8 @@ class RepoScraper:
|
|||
page = 0
|
||||
# Heavy nested PR query is capped at 3 per page (GitHub node-count
|
||||
# ceiling); light query skips reviewThreads/reviews/commits/etc and
|
||||
# can safely go to 25 per page. Clamp by trial_limit for small
|
||||
# previews so limit=1 does not fetch a whole 25-item page.
|
||||
# goes to 25 per page. Clamp by trial_limit so limit=1 does not
|
||||
# fetch a whole 25-item page.
|
||||
page_cap = 25 if self.light else 3
|
||||
trial_cap = self.trial_limits.get(key)
|
||||
per_page = min(page_cap, trial_cap) if trial_cap and trial_cap > 0 else page_cap
|
||||
|
|
@ -370,7 +370,7 @@ class RepoScraper:
|
|||
f["_owner"] = self.owner
|
||||
f["_repo"] = self.name
|
||||
f["_prNumber"] = number
|
||||
# files don't have id, synthesize one
|
||||
# files have no id; synthesize one
|
||||
f["_syntheticId"] = f"{self.owner}/{self.name}#{number}:{f.get('path')}"
|
||||
self.writers[out_key].write(f)
|
||||
info = ff.get("pageInfo") or {}
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ class JsonlWriter:
|
|||
self._lock = threading.Lock()
|
||||
self._fh = self.path.open("a", buffering = 1)
|
||||
self._count_seen_keys: set[str] = set()
|
||||
# Preload seen keys if file exists (for dedup across resumes)
|
||||
# Preload seen keys for dedup across resumes
|
||||
if self.path.exists() and self.path.stat().st_size > 0:
|
||||
try:
|
||||
with self.path.open() as f:
|
||||
|
|
|
|||
|
|
@ -70,11 +70,11 @@ def build_multi_file_preview_rows(
|
|||
|
||||
|
||||
def _round_robin_preview(rows: list[dict[str, str]], preview_size: int) -> list[dict[str, str]]:
|
||||
"""Pick preview rows round-robin across source files so every file is represented."""
|
||||
"""Pick preview rows round-robin across source files so each is represented."""
|
||||
if not rows or preview_size <= 0:
|
||||
return []
|
||||
|
||||
# Group rows by source_file, preserving order of first appearance
|
||||
# Group rows by source_file, preserving first-appearance order.
|
||||
from collections import OrderedDict
|
||||
|
||||
grouped: OrderedDict[str, list[dict[str, str]]] = OrderedDict()
|
||||
|
|
@ -147,7 +147,7 @@ def materialize_multi_file_unstructured_seed(
|
|||
chunk_size: int,
|
||||
chunk_overlap: int,
|
||||
) -> tuple[Path, list[dict[str, str]]]:
|
||||
"""Chunk multiple files and combine into one parquet dataset with source_file column."""
|
||||
"""Chunk multiple files into one parquet dataset with a source_file column."""
|
||||
chunk_size, chunk_overlap = resolve_chunking(chunk_size, chunk_overlap)
|
||||
cache_key = _compute_multi_file_cache_key(file_entries, chunk_size, chunk_overlap)
|
||||
cached = _CACHE_DIR / f"{cache_key}.parquet"
|
||||
|
|
|
|||
|
|
@ -2,14 +2,14 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Relax strict metadata pins so pip check matches known working single-env stack.
|
||||
"""Relax strict metadata pins so pip check passes on the single-env stack.
|
||||
|
||||
Why:
|
||||
- data-designer pins huggingface-hub>=1.0.1 and pyarrow<20.
|
||||
- unsloth/transformers pins huggingface-hub<1.
|
||||
- studio datasets pins pyarrow>=21.
|
||||
|
||||
Runtime works in this app with hub 0.36.x + pyarrow 23.x, but metadata conflicts.
|
||||
Runtime works with hub 0.36.x + pyarrow 23.x; only the metadata conflicts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
|
|||
|
|
@ -43,17 +43,17 @@ router = APIRouter()
|
|||
def _reset_password_command() -> str:
|
||||
"""Shell command shown in the 'incorrect password' hint.
|
||||
|
||||
Prefer the ABSOLUTE path to this install's ``unsloth`` launcher (a sibling
|
||||
of the running interpreter) so the hint works even when the launcher's
|
||||
directory is not on PATH -- e.g. a terminal opened before install, a stale
|
||||
Windows PATH, or ``~/.local/bin`` not on PATH (the default on macOS) -- and
|
||||
regardless of the current working directory.
|
||||
Prefer the ABSOLUTE path to this install's ``unsloth`` launcher (sibling of
|
||||
the running interpreter) so the hint works even when the launcher's dir
|
||||
isn't on PATH -- e.g. a terminal opened before install, a stale Windows
|
||||
PATH, or ``~/.local/bin`` not on PATH (the macOS default) -- regardless of
|
||||
the cwd.
|
||||
|
||||
On POSIX the path is shell-quoted so spaces are handled. On Windows we only
|
||||
use the bare absolute path when it has no spaces, because a quoted path needs
|
||||
different syntax in cmd (``"..."``) vs PowerShell (``& "..."``); when it has
|
||||
a space we fall back to the PATH-based form to stay unambiguous across
|
||||
shells. If the launcher can't be located we fall back to the PATH form too.
|
||||
On POSIX the path is shell-quoted to handle spaces. On Windows we use the
|
||||
bare absolute path only when it has no spaces, since a quoted path needs
|
||||
different syntax in cmd (``"..."``) vs PowerShell (``& "..."``); with a
|
||||
space we fall back to the PATH-based form to stay unambiguous across shells.
|
||||
We also fall back to the PATH form if the launcher can't be located.
|
||||
"""
|
||||
try:
|
||||
bin_dir = os.path.dirname(os.path.abspath(sys.executable))
|
||||
|
|
@ -72,7 +72,7 @@ def _reset_password_command() -> str:
|
|||
|
||||
# Per-(ip, username) bucket + per-IP aggregate. Account bucket stops one user's
|
||||
# typos from blocking others; the aggregate stops username-rotation spray.
|
||||
# Single-process only -- multi-worker deployments need a shared store.
|
||||
# Single-process only; multi-worker deployments need a shared store.
|
||||
_LOGIN_BUCKETS: dict[tuple[str, str], deque] = {}
|
||||
_LOGIN_IP_BUCKETS: dict[str, deque] = {}
|
||||
_LOGIN_BUCKETS_LOCK = threading.Lock()
|
||||
|
|
@ -80,18 +80,18 @@ _LOGIN_WINDOW_SECONDS = 60.0
|
|||
_LOGIN_MAX_FAILS = 5
|
||||
_LOGIN_IP_MAX_FAILS = 30
|
||||
_LOGIN_LOCKOUT_SECONDS = 60
|
||||
# Bucket-dict cap. On overflow we prune stale entries; if still full the
|
||||
# failure folds into the per-IP aggregate only.
|
||||
# Bucket-dict cap. On overflow, prune stale entries; if still full the failure
|
||||
# folds into the per-IP aggregate only.
|
||||
_LOGIN_MAX_BUCKETS = 4096
|
||||
# Unrepresentable as a real username (leading NUL); folds unknown-user attempts
|
||||
# into one slot so attacker cardinality cannot blow the bucket dict.
|
||||
# into one slot so attacker cardinality can't blow the bucket dict.
|
||||
_UNKNOWN_LOGIN_USER = "\x00unknown-user"
|
||||
|
||||
|
||||
def _trust_forwarded_for() -> bool:
|
||||
"""Honour X-Forwarded-For only when UNSLOTH_STUDIO_TRUST_FORWARDED is set.
|
||||
|
||||
Off by default so a direct caller cannot spoof the header.
|
||||
Off by default so a direct caller can't spoof the header.
|
||||
"""
|
||||
return os.environ.get("UNSLOTH_STUDIO_TRUST_FORWARDED", "").lower() in (
|
||||
"1",
|
||||
|
|
@ -112,7 +112,7 @@ def _normalize_forwarded_addr(value: str) -> str:
|
|||
return ""
|
||||
host = value[1:end]
|
||||
elif value.count(":") == 1:
|
||||
# IPv4:port. Bare IPv6 has multiple colons and takes the else branch.
|
||||
# IPv4:port. Bare IPv6 has multiple colons → else branch.
|
||||
head, _, tail = value.rpartition(":")
|
||||
host = head if tail.isdigit() and head else value
|
||||
else:
|
||||
|
|
@ -144,7 +144,7 @@ def _client_ip(request: Request | None) -> str:
|
|||
return normalized
|
||||
fwd = request.headers.get("forwarded", "")
|
||||
if fwd:
|
||||
# First element only -- multi-element headers cannot fork buckets.
|
||||
# First element only; multi-element headers can't fork buckets.
|
||||
normalized = _forwarded_for_from_element(fwd.split(",", 1)[0])
|
||||
if normalized:
|
||||
return normalized
|
||||
|
|
@ -190,7 +190,7 @@ def _record_login_failure(key: tuple[str, str]) -> int:
|
|||
_prune_bucket(account_bucket, now)
|
||||
account_bucket.append(now)
|
||||
return len(account_bucket)
|
||||
# Bucket dict is at its cap; per-IP cap still applies via ip_bucket.
|
||||
# Bucket dict at cap; per-IP cap still applies via ip_bucket.
|
||||
return len(ip_bucket)
|
||||
|
||||
|
||||
|
|
@ -242,16 +242,16 @@ async def login(payload: AuthLoginRequest, request: Request) -> Token:
|
|||
if blocked_for > 0:
|
||||
raise HTTPException(
|
||||
status_code = status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
# IP is intentionally not interpolated into the body; behind a
|
||||
# proxy or NAT it is either misleading or an info leak.
|
||||
# IP not interpolated into the body; behind a proxy/NAT it's
|
||||
# misleading or an info leak.
|
||||
detail = (f"Too many failed login attempts. " f"Try again in {blocked_for} seconds."),
|
||||
headers = {"Retry-After": str(blocked_for)},
|
||||
)
|
||||
|
||||
record = storage.get_user_and_secret(payload.username)
|
||||
if record is None:
|
||||
# Record under a single sentinel key per IP so attacker-controlled
|
||||
# username cardinality does not allocate buckets without bound.
|
||||
# Record under one sentinel key per IP so attacker-controlled username
|
||||
# cardinality can't allocate unbounded buckets.
|
||||
_record_login_failure(unknown_key)
|
||||
raise HTTPException(
|
||||
status_code = status.HTTP_401_UNAUTHORIZED,
|
||||
|
|
|
|||
|
|
@ -189,21 +189,20 @@ class ChatMessagesBatchResponse(BaseModel):
|
|||
|
||||
|
||||
class ChatImportLedgerResponse(BaseModel):
|
||||
# Plain list of legacy thread ids. Keeping the payload key-less keeps
|
||||
# the client diff to a single Set construction.
|
||||
# Plain list of legacy thread ids; key-less payload keeps the client diff
|
||||
# to a single Set construction.
|
||||
threadIds: list[str]
|
||||
|
||||
|
||||
class ChatImportLedgerRecordRequest(BaseModel):
|
||||
# 10k cap keeps the request body bounded; real users have << 1k threads.
|
||||
# 10k cap bounds the request body; real users have << 1k threads.
|
||||
threadIds: list[str] = Field(default_factory = list, max_length = 10_000)
|
||||
|
||||
|
||||
class ChatImportLedgerRecordResponse(BaseModel):
|
||||
# accepted: deduped non-empty input count. inserted: rows actually new
|
||||
# (ON CONFLICT DO NOTHING skips already-recorded ids). The client uses
|
||||
# `accepted >= 0` as the "endpoint exists" signal and ignores the split
|
||||
# otherwise.
|
||||
# `accepted >= 0` as the "endpoint exists" signal and ignores the split.
|
||||
accepted: int
|
||||
inserted: int
|
||||
|
||||
|
|
@ -353,7 +352,7 @@ async def batch_thread_messages(
|
|||
payload: ChatMessagesBatchRequest, current_subject: str = Depends(get_current_subject)
|
||||
):
|
||||
"""One round-trip per sidebar/search rebuild instead of N. Unknown thread
|
||||
ids are returned as empty lists so callers don't need a pre-flight."""
|
||||
ids return empty lists so callers don't need a pre-flight."""
|
||||
by_thread: dict[str, list[ChatMessage]] = {tid: [] for tid in payload.threadIds}
|
||||
for m in list_chat_messages_for_threads(payload.threadIds):
|
||||
tid = m["threadId"]
|
||||
|
|
@ -444,11 +443,10 @@ async def count_threads(current_subject: str = Depends(get_current_subject)):
|
|||
|
||||
@router.get("/import-ledger", response_model = ChatImportLedgerResponse)
|
||||
async def get_import_ledger(current_subject: str = Depends(get_current_subject)):
|
||||
"""Legacy-Dexie import ledger. Returns the set of legacy thread ids
|
||||
already copied into chat_threads / chat_messages. The frontend
|
||||
uses this on every fresh tab open to decide whether to re-run the
|
||||
Dexie -> studio.db import. Source of truth lives inside studio.db
|
||||
so a studio.db wipe makes the import recoverable."""
|
||||
"""Legacy-Dexie import ledger. Returns the legacy thread ids already copied
|
||||
into chat_threads / chat_messages. The frontend uses this on every fresh tab
|
||||
open to decide whether to re-run the Dexie -> studio.db import. Source of
|
||||
truth lives in studio.db, so a studio.db wipe makes the import recoverable."""
|
||||
return ChatImportLedgerResponse(threadIds = list_chat_legacy_imports())
|
||||
|
||||
|
||||
|
|
@ -480,8 +478,8 @@ async def put_settings(
|
|||
parsed = ChatSettingsPayload.model_validate(payload)
|
||||
except ValidationError as exc:
|
||||
raise HTTPException(status_code = 400, detail = exc.errors()) from exc
|
||||
# Atomic read + deep-merge + write inside one BEGIN IMMEDIATE so two
|
||||
# concurrent slider drags can't drop each other's updates.
|
||||
# Atomic read + deep-merge + write in one BEGIN IMMEDIATE so concurrent
|
||||
# slider drags can't drop each other's updates.
|
||||
try:
|
||||
return ChatSettingsResponse(
|
||||
settings = upsert_chat_settings_merge(parsed.model_dump(exclude_unset = True))
|
||||
|
|
|
|||
|
|
@ -36,14 +36,14 @@ def _resolve_local_v1_endpoint(request: Request) -> str:
|
|||
"""Return the loopback /v1 URL for the actual backend listen port.
|
||||
|
||||
Resolution order:
|
||||
1. ``app.state.server_port`` - explicitly published by run.py after
|
||||
the uvicorn server has bound. This is the most reliable source
|
||||
because it survives reverse proxies, TLS terminators and tunnels.
|
||||
1. ``app.state.server_port`` - published by run.py after uvicorn
|
||||
binds. Most reliable; survives reverse proxies, TLS terminators,
|
||||
and tunnels.
|
||||
2. ``request.scope["server"]`` - the real (host, port) tuple uvicorn
|
||||
sets when the request is dispatched. Used when Studio is started
|
||||
outside ``run_server`` (e.g. ``uvicorn studio.backend.main:app``).
|
||||
3. ``request.base_url`` parsed - last resort for test fixtures that
|
||||
do not route through a live uvicorn server.
|
||||
sets per request. Used when Studio starts outside ``run_server``
|
||||
(e.g. ``uvicorn studio.backend.main:app``).
|
||||
3. ``request.base_url`` parsed - last resort for test fixtures with
|
||||
no live uvicorn server.
|
||||
"""
|
||||
port: Any = getattr(request.app.state, "server_port", None)
|
||||
if not isinstance(port, int) or port <= 0:
|
||||
|
|
@ -76,14 +76,14 @@ def _request_has_desktop_access_token(request: Request) -> bool:
|
|||
|
||||
|
||||
def _used_llm_model_aliases(recipe: dict[str, Any]) -> set[str]:
|
||||
"""Return the set of model_aliases that are actually referenced by an
|
||||
LLM column. Used to narrow the "Chat model loaded" gate so that orphan
|
||||
model_config nodes on the canvas do not block unrelated recipe runs.
|
||||
"""Return model_aliases actually referenced by an LLM column. Narrows
|
||||
the "Chat model loaded" gate so orphan model_config nodes on the canvas
|
||||
do not block unrelated recipe runs.
|
||||
|
||||
The ``llm-`` prefix matches the existing convention in
|
||||
The ``llm-`` prefix matches the convention in
|
||||
``core/data_recipe/service.py::_recipe_has_llm_columns`` and covers all
|
||||
LLM column types emitted by the frontend (llm-text, llm-code,
|
||||
llm-structured, llm-judge).
|
||||
frontend LLM column types (llm-text, llm-code, llm-structured,
|
||||
llm-judge).
|
||||
"""
|
||||
aliases: set[str] = set()
|
||||
for column in recipe.get("columns", []):
|
||||
|
|
@ -180,18 +180,17 @@ def _ensure_selected_local_model_loaded(
|
|||
def _inject_local_structured_response_format(
|
||||
recipe: dict[str, Any], local_provider_names: set[str]
|
||||
) -> None:
|
||||
"""For each llm-structured column that targets a local-provider model_config,
|
||||
clone the model_config and inject an OpenAI ``response_format`` with the
|
||||
column's ``output_format`` JSON schema. The column is rewritten to point at
|
||||
the clone so llm-text / llm-judge columns that share the same alias keep
|
||||
free-form sampling.
|
||||
"""For each llm-structured column targeting a local-provider model_config,
|
||||
clone the config and inject an OpenAI ``response_format`` with the
|
||||
column's ``output_format`` JSON schema. The column is repointed at the
|
||||
clone so llm-text / llm-judge columns sharing the alias keep free-form
|
||||
sampling.
|
||||
|
||||
Without this, data_designer only injects a prompt-level "return JSON in a
|
||||
```json fence" instruction. Small GGUF models frequently break format,
|
||||
wasting the full ``max_tokens`` budget per row and then failing to parse.
|
||||
Forwarding ``response_format`` lets llama-server apply grammar-constrained
|
||||
sampling from the JSON schema, which guarantees a parseable response and
|
||||
terminates early.
|
||||
```json fence" instruction. Small GGUFs often break format, wasting the
|
||||
full ``max_tokens`` budget per row and then failing to parse. Forwarding
|
||||
``response_format`` lets llama-server apply grammar-constrained sampling
|
||||
from the schema, guaranteeing a parseable response and terminating early.
|
||||
"""
|
||||
columns = recipe.get("columns")
|
||||
model_configs = recipe.get("model_configs")
|
||||
|
|
@ -210,8 +209,8 @@ def _inject_local_structured_response_format(
|
|||
return
|
||||
|
||||
# Clone per (alias, column) so each llm-structured column gets its own
|
||||
# schema without leaking response_format onto other columns that share the
|
||||
# same base alias.
|
||||
# schema without leaking response_format onto other columns sharing the
|
||||
# base alias.
|
||||
seen_clone_aliases: set[str] = {
|
||||
mc.get("alias") for mc in model_configs if isinstance(mc.get("alias"), str)
|
||||
}
|
||||
|
|
@ -243,15 +242,14 @@ def _inject_local_structured_response_format(
|
|||
if not isinstance(params, dict):
|
||||
params = {}
|
||||
clone["inference_parameters"] = params
|
||||
# data_designer's BaseInferenceParams is a pydantic model with
|
||||
# extra="forbid", so response_format cannot sit at the top level of
|
||||
# inference_parameters. It does expose an `extra_body: dict` pass-
|
||||
# through that the OpenAI client spreads into the request body at the
|
||||
# top level, which is where llama-server reads response_format from.
|
||||
# llama.cpp server shape (tools/server/README.md): the schema sits
|
||||
# directly under response_format, not nested in a json_schema object
|
||||
# the way OpenAI's Chat Completions API expects. llama-server converts
|
||||
# the schema to a GBNF grammar and applies it during sampling.
|
||||
# data_designer's BaseInferenceParams is pydantic extra="forbid", so
|
||||
# response_format cannot sit at the top level of inference_parameters.
|
||||
# Its `extra_body: dict` passthrough is spread into the request body
|
||||
# top level by the OpenAI client, where llama-server reads
|
||||
# response_format. llama.cpp server shape (tools/server/README.md):
|
||||
# the schema sits directly under response_format, not nested in a
|
||||
# json_schema object like OpenAI's Chat Completions API expects.
|
||||
# llama-server converts the schema to a GBNF grammar for sampling.
|
||||
extra_body = params.get("extra_body")
|
||||
if not isinstance(extra_body, dict):
|
||||
extra_body = {}
|
||||
|
|
@ -269,21 +267,21 @@ def _inject_local_structured_response_format(
|
|||
|
||||
def _inject_local_providers(recipe: dict[str, Any], request: Request) -> Optional[int]:
|
||||
"""
|
||||
Mutate recipe dict in-place: for any provider with is_local=True,
|
||||
fill in the endpoint pointing at this server and inject a short-lived
|
||||
internal sk-unsloth-* API key for workflow auth.
|
||||
Mutate recipe dict in-place: for any provider with is_local=True, set
|
||||
the endpoint to this server and inject a short-lived internal
|
||||
sk-unsloth-* API key for workflow auth.
|
||||
|
||||
Returns the row id of the minted internal key (so the caller can
|
||||
revoke it on job completion) or ``None`` when no local provider is
|
||||
actually reachable from an LLM column.
|
||||
Returns the row id of the minted internal key (for the caller to revoke
|
||||
on job completion) or ``None`` when no local provider is reachable from
|
||||
an LLM column.
|
||||
"""
|
||||
providers = recipe.get("model_providers")
|
||||
if not providers:
|
||||
return None
|
||||
|
||||
# Collect local providers and pop is_local from ALL dicts unconditionally.
|
||||
# Strict `is True` guard so malformed payloads (is_local: 1,
|
||||
# is_local: "true") do not accidentally trigger the loopback rewrite.
|
||||
# Collect local providers and pop is_local from ALL dicts. Strict
|
||||
# `is True` guard so malformed payloads (is_local: 1, is_local: "true")
|
||||
# do not trigger the loopback rewrite.
|
||||
local_indices: list[int] = []
|
||||
for i, provider in enumerate(providers):
|
||||
if not isinstance(provider, dict):
|
||||
|
|
@ -297,10 +295,10 @@ def _inject_local_providers(recipe: dict[str, Any], request: Request) -> Optiona
|
|||
|
||||
endpoint = _resolve_local_v1_endpoint(request)
|
||||
|
||||
# Only gate on model-loaded if a local provider is actually reachable
|
||||
# from an LLM column through a model_config. Orphan model_config nodes
|
||||
# that reference a local provider but that no LLM column uses should
|
||||
# not block runs; the recipe would never call /v1 for them.
|
||||
# Only gate on model-loaded if a local provider is reachable from an LLM
|
||||
# column via a model_config. Orphan model_config nodes referencing a
|
||||
# local provider that no LLM column uses should not block runs; the
|
||||
# recipe would never call /v1 for them.
|
||||
local_names = {providers[i].get("name") for i in local_indices if providers[i].get("name")}
|
||||
used_aliases = _used_llm_model_aliases(recipe)
|
||||
referenced_providers = {
|
||||
|
|
@ -313,19 +311,19 @@ def _inject_local_providers(recipe: dict[str, Any], request: Request) -> Optiona
|
|||
internal_key_id: Optional[int] = None
|
||||
if local_names & referenced_providers:
|
||||
# Verify the selected local model is loaded before minting a workflow
|
||||
# key. This still remains a point-in-time singleton-backend check
|
||||
# (TOCTOU): a future generation token should bind frontend load and
|
||||
# job creation, and the inference endpoint returns a clear 400 if the
|
||||
# model is later unloaded or swapped before the subprocess calls /v1.
|
||||
# key. Still a point-in-time singleton-backend check (TOCTOU): a
|
||||
# future generation token should bind frontend load to job creation;
|
||||
# the inference endpoint returns a clear 400 if the model is unloaded
|
||||
# or swapped before the subprocess calls /v1.
|
||||
_ensure_selected_local_model_loaded(recipe, local_names)
|
||||
|
||||
from auth import storage # deferred: avoids circular import
|
||||
|
||||
# Mint an internal sk-unsloth-* key scoped to this workflow run.
|
||||
# Uses the unified API-key issuance path (one mint/revoke/verify
|
||||
# surface instead of a second JWT code path). The key is marked
|
||||
# internal so it is hidden from the user's API-key list, and the
|
||||
# caller revokes it when the job terminates.
|
||||
# Mint an internal sk-unsloth-* key scoped to this workflow run via
|
||||
# the unified API-key issuance path (one mint/revoke/verify surface
|
||||
# instead of a second JWT code path). Marked internal so it is hidden
|
||||
# from the user's API-key list; the caller revokes it when the job
|
||||
# terminates.
|
||||
expires_at = (datetime.now(timezone.utc) + timedelta(hours = 24)).isoformat()
|
||||
token, row = storage.create_api_key(
|
||||
username = "unsloth",
|
||||
|
|
@ -335,11 +333,11 @@ def _inject_local_providers(recipe: dict[str, Any], request: Request) -> Optiona
|
|||
)
|
||||
internal_key_id = int(row["id"])
|
||||
|
||||
# Defensively strip any stale "external"-only fields the frontend may
|
||||
# have left on the dict (extra_headers/extra_body/api_key_env). The UI
|
||||
# hides these inputs in local mode but the payload builder still serializes
|
||||
# them, so a previously external provider that flipped to local can carry
|
||||
# invalid JSON or rogue auth headers into the local /v1 call.
|
||||
# Strip stale "external"-only fields the frontend may have left
|
||||
# (extra_headers/extra_body/api_key_env). The UI hides these in local
|
||||
# mode but the payload builder still serializes them, so a provider
|
||||
# flipped from external to local could carry invalid JSON or rogue auth
|
||||
# headers into the local /v1 call.
|
||||
for i in local_indices:
|
||||
providers[i]["endpoint"] = endpoint
|
||||
providers[i]["api_key"] = token
|
||||
|
|
@ -348,29 +346,27 @@ def _inject_local_providers(recipe: dict[str, Any], request: Request) -> Optiona
|
|||
providers[i].pop("extra_headers", None)
|
||||
providers[i].pop("extra_body", None)
|
||||
|
||||
# Force skip_health_check on any model_config that references a local
|
||||
# provider. The frontend now sends the explicit selected local model id,
|
||||
# but llama-server's /v1/models response can still differ from that id
|
||||
# for local paths, cache aliases, and GGUF variant loads. The recipe run
|
||||
# has already gated on a loaded local inference backend above, so the
|
||||
# data_designer model-list health check would be redundant and can reject
|
||||
# valid local selections.
|
||||
# Force skip_health_check on any model_config referencing a local
|
||||
# provider. The frontend sends the explicit selected local model id, but
|
||||
# llama-server's /v1/models response can differ from it for local paths,
|
||||
# cache aliases, and GGUF variant loads. The run already gated on a
|
||||
# loaded local backend above, so data_designer's model-list health check
|
||||
# would be redundant and can reject valid local selections.
|
||||
for mc in recipe.get("model_configs", []):
|
||||
if not isinstance(mc, dict):
|
||||
continue
|
||||
if mc.get("provider") in local_names:
|
||||
mc["skip_health_check"] = True
|
||||
# Disable thinking for data-recipe inference on local providers.
|
||||
# Reasoning models emit a <think>...</think> preamble before the
|
||||
# answer, which roughly doubles generated token count per row and
|
||||
# pushes the visible answer past data_designer's json-fence
|
||||
# regex. Forward chat_template_kwargs={enable_thinking: False}
|
||||
# through the OpenAI SDK's extra_body passthrough so llama-server
|
||||
# renders the template without the reasoning preamble. Free-form
|
||||
# llm-text columns benefit from the latency cut, and structured
|
||||
# columns also stop leaking think tags into the grammar-
|
||||
# constrained JSON (llama-server's GBNF path still enforces the
|
||||
# schema either way).
|
||||
# Reasoning models emit a <think>...</think> preamble that roughly
|
||||
# doubles generated tokens per row and pushes the answer past
|
||||
# data_designer's json-fence regex. Forward
|
||||
# chat_template_kwargs={enable_thinking: False} via the OpenAI
|
||||
# SDK's extra_body passthrough so llama-server renders the
|
||||
# template without the preamble. llm-text columns get the latency
|
||||
# cut; structured columns stop leaking think tags into the
|
||||
# grammar-constrained JSON (GBNF still enforces the schema either
|
||||
# way).
|
||||
params = mc.get("inference_parameters")
|
||||
if not isinstance(params, dict):
|
||||
params = {}
|
||||
|
|
@ -387,7 +383,7 @@ def _inject_local_providers(recipe: dict[str, Any], request: Request) -> Optiona
|
|||
|
||||
# Forward each llm-structured column's output_format as an OpenAI
|
||||
# response_format so llama-server uses grammar-constrained sampling and
|
||||
# small GGUFs stop wasting the full max_tokens budget on broken JSON.
|
||||
# small GGUFs stop wasting the max_tokens budget on broken JSON.
|
||||
_inject_local_structured_response_format(recipe, local_names)
|
||||
|
||||
return internal_key_id
|
||||
|
|
@ -446,11 +442,11 @@ def create_job(payload: RecipePayload, request: Request):
|
|||
log = logger,
|
||||
) from exc
|
||||
|
||||
# Single try block covers get_job_manager() AND mgr.start() so a workflow
|
||||
# key minted above never outlives the request even when an unexpected
|
||||
# Single try covers get_job_manager() AND mgr.start() so a minted
|
||||
# workflow key never outlives the request even on an unexpected
|
||||
# exception type (TypeError from a stale kwarg, OSError from a queue
|
||||
# write, etc.) bubbles up. Without the bare except, such exceptions let
|
||||
# the sk-unsloth-* key live until its 24h TTL.
|
||||
# write, etc.). Without the bare except, those would let the
|
||||
# sk-unsloth-* key live until its 24h TTL.
|
||||
try:
|
||||
mgr = get_job_manager()
|
||||
job_id = mgr.start(
|
||||
|
|
@ -488,7 +484,7 @@ def create_job(payload: RecipePayload, request: Request):
|
|||
|
||||
def _revoke_internal_api_key_safe(key_id: int) -> None:
|
||||
"""Best-effort revoke of a workflow-minted key; swallow any error so
|
||||
that revocation failures never mask the caller's own error path."""
|
||||
revocation failures never mask the caller's own error path."""
|
||||
try:
|
||||
from auth import storage # deferred: avoids circular import
|
||||
storage.revoke_internal_api_key(key_id)
|
||||
|
|
|
|||
|
|
@ -394,7 +394,7 @@ def inspect_seed_dataset(payload: SeedInspectRequest) -> SeedInspectResponse:
|
|||
|
||||
|
||||
def _extract_text_from_file(file_path: Path, ext: str) -> str:
|
||||
"""Extract text from uploaded file based on extension, converting to markdown where possible."""
|
||||
"""Extract text from an uploaded file by extension, to markdown where possible."""
|
||||
if ext in {".txt", ".md"}:
|
||||
raw = file_path.read_text(encoding = "utf-8", errors = "ignore")
|
||||
elif ext == ".pdf":
|
||||
|
|
@ -581,7 +581,7 @@ def inspect_seed_upload(payload: SeedInspectUploadRequest) -> SeedInspectRespons
|
|||
seed_source_type = _normalize_optional_text(payload.seed_source_type) or "local"
|
||||
filename = _sanitize_filename(payload.filename)
|
||||
ext = Path(filename).suffix.lower()
|
||||
# Legacy single-file unstructured path only supports .txt/.md
|
||||
# Legacy single-file unstructured path supports only .txt/.md;
|
||||
# PDF/DOCX extraction uses the multi-file upload endpoint instead
|
||||
_LEGACY_UNSTRUCTURED_EXTS = {".txt", ".md"}
|
||||
if seed_source_type == "unstructured":
|
||||
|
|
|
|||
|
|
@ -121,9 +121,8 @@ def _collect_validation_errors(recipe: dict[str, Any]) -> list[ValidateError]:
|
|||
def _patch_local_providers(recipe: dict[str, Any]) -> None:
|
||||
"""Strip is_local and fill a dummy endpoint so validation doesn't choke.
|
||||
|
||||
Uses a strict `is True` check to match _inject_local_providers in
|
||||
jobs.py - malformed payloads with truthy but non-boolean is_local
|
||||
values should not be treated as local.
|
||||
Strict `is True` check matches _inject_local_providers in jobs.py:
|
||||
truthy but non-boolean is_local values are not treated as local.
|
||||
"""
|
||||
for provider in recipe.get("model_providers", []):
|
||||
if not isinstance(provider, dict):
|
||||
|
|
@ -152,13 +151,12 @@ def validate(payload: RecipePayload) -> ValidateResponse:
|
|||
build_config_builder(recipe)
|
||||
except ModuleNotFoundError as exc:
|
||||
# data_designer is an optional runtime dep. Static validation
|
||||
# already passed; live access + full config validation are
|
||||
# deferred to run start (per _GITHUB_VALIDATE_NOTE), so a missing
|
||||
# optional import at validate time should not block the recipe.
|
||||
# Restrict the bypass to the data_designer module specifically so
|
||||
# other ImportErrors (e.g. broken internal imports or missing
|
||||
# transitive deps after a package upgrade) still surface as
|
||||
# validation failures instead of being silently swallowed.
|
||||
# passed; live access + full config validation are deferred to
|
||||
# run start (per _GITHUB_VALIDATE_NOTE), so a missing optional
|
||||
# import here should not block the recipe. Restrict the bypass to
|
||||
# the data_designer module so other ImportErrors (broken internal
|
||||
# imports, missing transitive deps after an upgrade) still surface
|
||||
# as validation failures instead of being swallowed.
|
||||
if not (exc.name or "").startswith("data_designer"):
|
||||
raise
|
||||
logger.debug(
|
||||
|
|
|
|||
|
|
@ -45,9 +45,9 @@ def _get_dataset_size_cached(repo_id: str) -> int:
|
|||
def _resolve_hf_cache_realpath(repo_dir: Path) -> Optional[str]:
|
||||
"""Pick the most useful on-disk path for a HF cache repo dir.
|
||||
|
||||
Mirrors the helper in routes/models.py: prefer the most-recent
|
||||
snapshot dir, fall back to the cache repo root, return resolved
|
||||
realpath. Duplicated here to keep routes/datasets.py self-contained.
|
||||
Mirrors routes/models.py: prefer the most-recent snapshot dir, else the
|
||||
cache repo root, returned as a resolved realpath. Duplicated here to keep
|
||||
routes/datasets.py self-contained.
|
||||
"""
|
||||
try:
|
||||
snapshots_dir = repo_dir / "snapshots"
|
||||
|
|
@ -61,12 +61,12 @@ def _resolve_hf_cache_realpath(repo_dir: Path) -> Optional[str]:
|
|||
return None
|
||||
|
||||
|
||||
# Add backend directory to path
|
||||
# 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))
|
||||
|
||||
# Import dataset utilities
|
||||
# Import dataset utilities.
|
||||
from utils.datasets import check_dataset_format
|
||||
from utils.upload_limits import get_upload_limit_bytes, get_upload_limit_label
|
||||
from auth.authentication import get_current_subject
|
||||
|
|
@ -93,7 +93,7 @@ from utils.paths import (
|
|||
|
||||
|
||||
def _serialize_preview_value(value):
|
||||
"""make it json safe for client preview ⊂(◉‿◉)つ"""
|
||||
"""Make a value JSON-safe for the client preview."""
|
||||
if value is None or isinstance(value, (str, int, float, bool)):
|
||||
return value
|
||||
|
||||
|
|
@ -130,10 +130,10 @@ def _serialize_preview_rows(rows):
|
|||
|
||||
# --- Endpoints ---
|
||||
|
||||
# Recognized data-file extensions for the single-file fallback approach.
|
||||
# Tabular formats are preferred over archives for Tier 1 preview because
|
||||
# archives (e.g. images.zip) may be loaded as ImageFolder datasets with
|
||||
# synthetic columns (image/label) that don't match the real dataset schema.
|
||||
# Recognized data-file extensions for the single-file fallback.
|
||||
# Tier 1 preview prefers tabular over archives: archives (e.g. images.zip) can
|
||||
# load as ImageFolder datasets with synthetic image/label columns that don't
|
||||
# match the real schema.
|
||||
_TABULAR_EXTS = (".parquet", ".json", ".jsonl", ".csv", ".tsv", ".arrow")
|
||||
_ARCHIVE_EXTS = (".tar", ".tar.gz", ".tgz", ".gz", ".zst", ".zip", ".txt")
|
||||
DATA_EXTS = _TABULAR_EXTS + _ARCHIVE_EXTS
|
||||
|
|
@ -331,9 +331,9 @@ async def upload_dataset(
|
|||
stored_name = f"{uuid4().hex}_{stem}{ext}"
|
||||
stored_path = DATASET_UPLOAD_DIR / stored_name
|
||||
|
||||
# Stream file to disk in chunks to avoid holding entire file in memory.
|
||||
# Keep a route-level cap so users get a clear training-dataset-specific
|
||||
# error and oversized partial files are not left in the Studio uploads directory.
|
||||
# Stream to disk in chunks to avoid holding the whole file in memory. The
|
||||
# route-level cap gives a clear training-dataset error and avoids leaving
|
||||
# oversized partial files in the Studio uploads directory.
|
||||
upload_limit_bytes = get_upload_limit_bytes()
|
||||
total_bytes = 0
|
||||
upload_complete = False
|
||||
|
|
@ -378,12 +378,10 @@ async def get_dataset_download_progress(
|
|||
"""Return download progress for a HuggingFace dataset repo.
|
||||
|
||||
Mirrors ``GET /api/models/download-progress`` but scans the
|
||||
``datasets--owner--name`` cache directory under HF_HUB_CACHE.
|
||||
Modern ``datasets``/``huggingface_hub`` caches both raw model and
|
||||
raw dataset blobs in HF_HUB_CACHE; the ``datasets`` library writes
|
||||
its processed Arrow shards elsewhere, but the in-progress *download*
|
||||
bytes are observable here. Returns ``cache_path`` so the UI can
|
||||
show users where the dataset blobs landed on disk.
|
||||
``datasets--owner--name`` cache directory under HF_HUB_CACHE. Modern
|
||||
``datasets``/``huggingface_hub`` cache raw dataset blobs there (processed
|
||||
Arrow shards live elsewhere, but in-progress *download* bytes are visible
|
||||
here). Returns ``cache_path`` so the UI can show where blobs landed.
|
||||
"""
|
||||
_empty = {
|
||||
"downloaded_bytes": 0,
|
||||
|
|
@ -433,9 +431,9 @@ async def get_dataset_download_progress(
|
|||
"cache_path": cache_path,
|
||||
}
|
||||
|
||||
# Same 95% completion threshold as the model endpoint -- HF blob
|
||||
# dedup makes completed_bytes drift slightly under expected_bytes,
|
||||
# and inter-file gaps would otherwise look like "done".
|
||||
# Same 95% completion threshold as the model endpoint -- HF blob dedup
|
||||
# makes completed_bytes drift slightly under expected_bytes, and
|
||||
# inter-file gaps would otherwise look "done".
|
||||
if completed_bytes >= expected_bytes * 0.95:
|
||||
progress = 1.0
|
||||
else:
|
||||
|
|
@ -456,15 +454,13 @@ def check_format(request: CheckFormatRequest, current_subject: str = Depends(get
|
|||
"""
|
||||
Check if a dataset requires manual column mapping.
|
||||
|
||||
Strategy for HuggingFace datasets:
|
||||
1. list_repo_files → pick the first data file → load_dataset(data_files=[…])
|
||||
HuggingFace strategy:
|
||||
1. list_repo_files → first data file → load_dataset(data_files=[…]).
|
||||
Avoids resolving thousands of files; typically ~2-4 s.
|
||||
2. Full streaming load_dataset as a last-resort fallback.
|
||||
|
||||
Local files are loaded directly.
|
||||
|
||||
Using a plain `def` (not async) so FastAPI runs this in a thread-pool,
|
||||
preventing any blocking IO from freezing the event loop.
|
||||
Local files load directly. Plain `def` (not async) so FastAPI runs it in a
|
||||
thread-pool, keeping blocking IO off the event loop.
|
||||
"""
|
||||
try:
|
||||
from itertools import islice
|
||||
|
|
@ -502,14 +498,14 @@ def check_format(request: CheckFormatRequest, current_subject: str = Depends(get
|
|||
)
|
||||
data_files = [f for f in repo_files if any(f.endswith(ext) for ext in DATA_EXTS)]
|
||||
|
||||
# Prefer tabular formats over archives (e.g. images.zip → ImageFolder
|
||||
# with synthetic image/label columns that don't match the real schema).
|
||||
# Prefer tabular over archives (e.g. images.zip → ImageFolder
|
||||
# with synthetic image/label columns not in the real schema).
|
||||
tabular_files = [
|
||||
f for f in data_files if any(f.endswith(ext) for ext in _TABULAR_EXTS)
|
||||
]
|
||||
candidates = tabular_files or data_files
|
||||
|
||||
# When a subset is specified, narrow to files whose name matches
|
||||
# With a subset, narrow to files whose name matches
|
||||
# (e.g. subset="testmini" → prefer "testmini.parquet").
|
||||
if request.subset and candidates:
|
||||
subset_matches = [f for f in candidates if request.subset in Path(f).stem]
|
||||
|
|
@ -560,26 +556,26 @@ def check_format(request: CheckFormatRequest, current_subject: str = Depends(get
|
|||
preview_slice = Dataset.from_list(rows)
|
||||
total_rows = None
|
||||
|
||||
# Run lightweight format check on the preview slice
|
||||
# Lightweight format check on the preview slice.
|
||||
result = check_dataset_format(preview_slice, is_vlm = request.is_vlm)
|
||||
|
||||
logger.info(
|
||||
f"Format check result: requires_mapping={result['requires_manual_mapping']}, format={result['detected_format']}, is_image={result.get('is_image', False)}"
|
||||
)
|
||||
|
||||
# Generate preview samples
|
||||
# Generate preview samples.
|
||||
preview_samples = None
|
||||
if not result["requires_manual_mapping"]:
|
||||
if result.get("suggested_mapping"):
|
||||
# Heuristic-detected: show raw data so columns match the API response.
|
||||
# Processing (column stripping) happens at training time, not preview.
|
||||
# Column stripping happens at training time, not preview.
|
||||
preview_samples = _serialize_preview_rows(preview_slice)
|
||||
else:
|
||||
try:
|
||||
format_result = format_dataset(
|
||||
preview_slice,
|
||||
format_type = "auto",
|
||||
num_proc = None, # Only 10 preview rows -- no need for multiprocessing
|
||||
num_proc = None, # Only 10 preview rows -- no multiprocessing needed
|
||||
)
|
||||
processed = format_result["dataset"]
|
||||
preview_samples = _serialize_preview_rows(processed)
|
||||
|
|
@ -589,7 +585,7 @@ def check_format(request: CheckFormatRequest, current_subject: str = Depends(get
|
|||
else:
|
||||
preview_samples = _serialize_preview_rows(preview_slice)
|
||||
|
||||
# Collect warnings: from check_dataset_format + URL-based image detection
|
||||
# Collect warnings from check_dataset_format + URL-based image detection.
|
||||
warning = result.get("warning")
|
||||
image_col = result.get("detected_image_column")
|
||||
if image_col and image_col in (result.get("columns") or []):
|
||||
|
|
@ -636,17 +632,17 @@ def ai_assist_mapping(
|
|||
"""
|
||||
Run LLM-assisted dataset conversion advisor (user-triggered).
|
||||
|
||||
Multi-pass analysis using a 7B helper model:
|
||||
Pass 1: Classify dataset type from HF card + samples
|
||||
Pass 2: Generate conversion strategy (system prompt, templates)
|
||||
Pass 3: Validate conversion quality
|
||||
Multi-pass analysis with a 7B helper model:
|
||||
Pass 1: Classify dataset type from HF card + samples.
|
||||
Pass 2: Generate conversion strategy (system prompt, templates).
|
||||
Pass 3: Validate conversion quality.
|
||||
|
||||
Falls back to simple column classification if the advisor fails.
|
||||
"""
|
||||
try:
|
||||
from utils.datasets.llm_assist import llm_conversion_advisor
|
||||
|
||||
# Truncate sample values for the LLM prompt
|
||||
# Truncate sample values for the LLM prompt.
|
||||
truncated = [
|
||||
{col: str(s.get(col, ""))[:200] for col in request.columns} for s in request.samples[:5]
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""
|
||||
Export API routes: checkpoint discovery and model export operations.
|
||||
"""
|
||||
"""Export API routes: checkpoint discovery and model export operations."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
|
@ -28,7 +26,7 @@ from auth.authentication import get_current_subject
|
|||
|
||||
from utils.utils import safe_error_detail
|
||||
|
||||
# Import backend functions
|
||||
# Backend functions
|
||||
try:
|
||||
from core.export import get_export_backend
|
||||
except ImportError:
|
||||
|
|
@ -37,7 +35,7 @@ except ImportError:
|
|||
sys.path.insert(0, str(parent_backend))
|
||||
from core.export import get_export_backend
|
||||
|
||||
# Import Pydantic models
|
||||
# Pydantic models
|
||||
from models import (
|
||||
LoadCheckpointRequest,
|
||||
ExportStatusResponse,
|
||||
|
|
@ -56,16 +54,12 @@ logger = get_logger(__name__)
|
|||
async def load_checkpoint(
|
||||
request: LoadCheckpointRequest, current_subject: str = Depends(get_current_subject)
|
||||
):
|
||||
"""
|
||||
Load a checkpoint into the export backend.
|
||||
|
||||
Wraps ExportBackend.load_checkpoint.
|
||||
"""
|
||||
"""Load a checkpoint into the export backend (ExportBackend.load_checkpoint)."""
|
||||
try:
|
||||
# Version switching is handled automatically by the subprocess-based
|
||||
# export backend — no need for ensure_transformers_version() here.
|
||||
# The subprocess-based export backend handles version switching
|
||||
# automatically — no ensure_transformers_version() needed here.
|
||||
|
||||
# Free GPU memory: shut down any running inference/training subprocesses
|
||||
# Free GPU memory: shut down running inference/training subprocesses
|
||||
# before loading the export checkpoint (they'd compete for VRAM).
|
||||
try:
|
||||
from core.inference import get_inference_backend
|
||||
|
|
@ -87,8 +81,8 @@ async def load_checkpoint(
|
|||
if trn.is_training_active():
|
||||
logger.info("Stopping active training to free GPU memory for export")
|
||||
trn.stop_training()
|
||||
# Wait for training subprocess to actually exit before proceeding,
|
||||
# otherwise it may still hold GPU memory when export tries to load.
|
||||
# Wait for the training subprocess to exit; else it may still
|
||||
# hold GPU memory when export tries to load.
|
||||
for _ in range(60): # up to 30s
|
||||
if not trn.is_training_active():
|
||||
break
|
||||
|
|
@ -101,8 +95,8 @@ async def load_checkpoint(
|
|||
|
||||
backend = get_export_backend()
|
||||
# load_checkpoint spawns and waits on a subprocess and can take
|
||||
# minutes. Run it in a worker thread so the event loop stays
|
||||
# free to serve the live log SSE stream concurrently.
|
||||
# minutes. Run it in a worker thread so the event loop stays free
|
||||
# to serve the live log SSE stream concurrently.
|
||||
success, message = await asyncio.to_thread(
|
||||
backend.load_checkpoint,
|
||||
checkpoint_path = request.checkpoint_path,
|
||||
|
|
@ -127,11 +121,7 @@ async def load_checkpoint(
|
|||
|
||||
@router.post("/cleanup", response_model = ExportOperationResponse)
|
||||
async def cleanup_export_memory(current_subject: str = Depends(get_current_subject)):
|
||||
"""
|
||||
Cleanup export-related models from memory (GPU/CPU).
|
||||
|
||||
Wraps ExportBackend.cleanup_memory.
|
||||
"""
|
||||
"""Cleanup export-related models from memory (ExportBackend.cleanup_memory)."""
|
||||
try:
|
||||
backend = get_export_backend()
|
||||
success = await asyncio.to_thread(backend.cleanup_memory)
|
||||
|
|
@ -158,9 +148,7 @@ async def cleanup_export_memory(current_subject: str = Depends(get_current_subje
|
|||
|
||||
@router.get("/status", response_model = ExportStatusResponse)
|
||||
async def get_export_status(current_subject: str = Depends(get_current_subject)):
|
||||
"""
|
||||
Get current export backend status (loaded checkpoint, model type, PEFT flag).
|
||||
"""
|
||||
"""Get export backend status (loaded checkpoint, model type, PEFT flag)."""
|
||||
try:
|
||||
backend = get_export_backend()
|
||||
return ExportStatusResponse(
|
||||
|
|
@ -177,7 +165,7 @@ async def get_export_status(current_subject: str = Depends(get_current_subject))
|
|||
|
||||
|
||||
def _export_details(output_path: Optional[str]) -> Optional[Dict[str, Any]]:
|
||||
"""Return the export path relative to exports_root so the install path is not leaked."""
|
||||
"""Return the export path relative to exports_root, hiding the install path."""
|
||||
if not output_path:
|
||||
return None
|
||||
try:
|
||||
|
|
@ -195,8 +183,7 @@ def _export_details(output_path: Optional[str]) -> Optional[Dict[str, Any]]:
|
|||
async def export_merged_model(
|
||||
request: ExportMergedModelRequest, current_subject: str = Depends(get_current_subject)
|
||||
):
|
||||
"""
|
||||
Export a merged PEFT model (e.g., 16-bit or 4-bit) and optionally push to Hub.
|
||||
"""Export a merged PEFT model (16-bit or 4-bit), optionally pushing to Hub.
|
||||
|
||||
Wraps ExportBackend.export_merged_model.
|
||||
"""
|
||||
|
|
@ -234,8 +221,7 @@ async def export_merged_model(
|
|||
async def export_base_model(
|
||||
request: ExportBaseModelRequest, current_subject: str = Depends(get_current_subject)
|
||||
):
|
||||
"""
|
||||
Export a non-PEFT base model and optionally push to Hub.
|
||||
"""Export a non-PEFT base model, optionally pushing to Hub.
|
||||
|
||||
Wraps ExportBackend.export_base_model.
|
||||
"""
|
||||
|
|
@ -273,8 +259,7 @@ async def export_base_model(
|
|||
async def export_gguf(
|
||||
request: ExportGGUFRequest, current_subject: str = Depends(get_current_subject)
|
||||
):
|
||||
"""
|
||||
Export the current model to GGUF format and optionally push to Hub.
|
||||
"""Export the current model to GGUF format, optionally pushing to Hub.
|
||||
|
||||
Wraps ExportBackend.export_gguf.
|
||||
"""
|
||||
|
|
@ -311,8 +296,7 @@ async def export_gguf(
|
|||
async def export_lora_adapter(
|
||||
request: ExportLoRAAdapterRequest, current_subject: str = Depends(get_current_subject)
|
||||
):
|
||||
"""
|
||||
Export only the LoRA adapter (if the loaded model is PEFT).
|
||||
"""Export only the LoRA adapter (if the loaded model is PEFT).
|
||||
|
||||
Wraps ExportBackend.export_lora_adapter.
|
||||
"""
|
||||
|
|
@ -349,18 +333,18 @@ async def export_lora_adapter(
|
|||
# Live export log stream (Server-Sent Events)
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
#
|
||||
# The export worker subprocess redirects its stdout/stderr into a pipe
|
||||
# that a reader thread forwards to the orchestrator as log entries (see
|
||||
# The export worker subprocess redirects its stdout/stderr into a pipe a
|
||||
# reader thread forwards to the orchestrator as log entries (see
|
||||
# core/export/worker.py::_setup_log_capture and
|
||||
# core/export/orchestrator.py::_append_log). This endpoint streams
|
||||
# those entries to the browser so the export dialog can show a live
|
||||
# terminal-style output panel while load_checkpoint / export_merged /
|
||||
# export_gguf / export_lora / export_base run.
|
||||
# core/export/orchestrator.py::_append_log). This endpoint streams those
|
||||
# entries to the browser so the export dialog shows a live terminal-style
|
||||
# panel while load_checkpoint / export_merged / export_gguf / export_lora /
|
||||
# export_base run.
|
||||
#
|
||||
# Shape follows the training progress SSE endpoint
|
||||
# (routes/training.py::stream_training_progress): each event carries
|
||||
# `id`, `event`, and `data` fields, the stream starts with a `retry:`
|
||||
# directive, and `Last-Event-ID` is honored on reconnect.
|
||||
# (routes/training.py::stream_training_progress): each event carries `id`,
|
||||
# `event`, `data` fields, the stream starts with a `retry:` directive, and
|
||||
# `Last-Event-ID` is honored on reconnect.
|
||||
|
||||
|
||||
def _format_sse(
|
||||
|
|
@ -389,28 +373,26 @@ async def stream_export_logs(
|
|||
current_subject: str = Depends(get_current_subject),
|
||||
):
|
||||
"""
|
||||
Stream live stdout/stderr output from the export worker subprocess
|
||||
as Server-Sent Events.
|
||||
Stream live stdout/stderr from the export worker subprocess as
|
||||
Server-Sent Events.
|
||||
|
||||
Events:
|
||||
- `log` : a single log line (data: {"stream","line","ts"})
|
||||
- `heartbeat`: periodic keepalive when no new lines are available
|
||||
- `complete` : emitted once the export worker is idle and no new
|
||||
lines arrived for ~1 second. Clients should close.
|
||||
- `complete` : once the worker is idle and no new lines arrived for
|
||||
~1 second. Clients should close.
|
||||
- `error` : unrecoverable server-side error
|
||||
|
||||
The `id:` field on each event is the log entry's monotonic seq
|
||||
number so the browser can resume via `Last-Event-ID` on reconnect.
|
||||
Each event's `id:` field is the log entry's monotonic seq number so the
|
||||
browser can resume via `Last-Event-ID` on reconnect.
|
||||
"""
|
||||
backend = get_export_backend()
|
||||
|
||||
# Determine starting cursor. Explicit `since` wins, then
|
||||
# Last-Event-ID header on reconnect, otherwise start from the
|
||||
# run-start snapshot captured by clear_logs() so the client sees
|
||||
# every line emitted since the current run began -- even if the
|
||||
# SSE connection opened after the POST that kicked off the export.
|
||||
# Using get_current_log_seq() here would lose the early bootstrap
|
||||
# lines that arrive in the gap between POST and SSE connect.
|
||||
# Starting cursor: explicit `since` wins, then Last-Event-ID header on
|
||||
# reconnect, else the run-start snapshot from clear_logs() so the client
|
||||
# sees every line since the run began -- even if the SSE connection
|
||||
# opened after the POST that kicked off the export. get_current_log_seq()
|
||||
# would lose the early bootstrap lines arriving between POST and connect.
|
||||
last_event_id = request.headers.get("last-event-id")
|
||||
if since is None and last_event_id is not None:
|
||||
try:
|
||||
|
|
@ -425,8 +407,7 @@ async def stream_export_logs(
|
|||
|
||||
async def event_generator() -> AsyncGenerator[str, None]:
|
||||
nonlocal cursor
|
||||
# Tell the browser to reconnect after 3 seconds if the
|
||||
# connection drops mid-export.
|
||||
# Reconnect after 3 seconds if the connection drops mid-export.
|
||||
yield "retry: 3000\n\n"
|
||||
|
||||
last_yield = time.monotonic()
|
||||
|
|
@ -460,9 +441,8 @@ async def stream_export_logs(
|
|||
yield _format_sse("{}", event = "heartbeat")
|
||||
last_yield = now
|
||||
if not backend.is_export_active():
|
||||
# Give the reader thread a moment to drain any
|
||||
# trailing lines the worker process printed
|
||||
# just before signalling done.
|
||||
# Give the reader thread a moment to drain trailing
|
||||
# lines the worker printed just before signalling done.
|
||||
if idle_since is None:
|
||||
idle_since = now
|
||||
elif now - idle_since > 1.0:
|
||||
|
|
@ -477,8 +457,8 @@ async def stream_export_logs(
|
|||
|
||||
await asyncio.sleep(0.1)
|
||||
except asyncio.CancelledError:
|
||||
# Client disconnected mid-yield. Don't re-raise, just end
|
||||
# the generator cleanly so StreamingResponse finalizes.
|
||||
# Client disconnected mid-yield. Don't re-raise; end the
|
||||
# generator cleanly so StreamingResponse finalizes.
|
||||
return
|
||||
except Exception as exc:
|
||||
logger.error("Export log stream failed: %s", exc, exc_info = True)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -34,9 +34,9 @@ router = APIRouter()
|
|||
|
||||
|
||||
def _looks_like_command(value: str) -> bool:
|
||||
"""Whitespace is a one-way signal: a URL can't hold an unencoded space, so a
|
||||
value with whitespace is definitely a command. No whitespace proves nothing
|
||||
(a lone token may be a single-arg command or a scheme-less URL)."""
|
||||
"""Whitespace is a one-way signal: a URL can't hold an unencoded space, so
|
||||
a value with whitespace is definitely a command. No whitespace proves
|
||||
nothing (a lone token may be a single-arg command or a scheme-less URL)."""
|
||||
return any(ch.isspace() for ch in value)
|
||||
|
||||
|
||||
|
|
@ -46,8 +46,8 @@ def _validate_url(url: str) -> str:
|
|||
raise HTTPException(status_code = 400, detail = "url must not be empty")
|
||||
# When stdio is enabled on this host, a non-HTTP value is a local command.
|
||||
# Reuse this field so stdio servers ride the existing CRUD/storage with no
|
||||
# schema change. A lone token (example.com, /usr/bin/srv) is ambiguous, so we
|
||||
# keep the existing behaviour and treat any non-HTTP value as a command here.
|
||||
# schema change. A lone token (example.com, /usr/bin/srv) is ambiguous, so
|
||||
# keep existing behaviour and treat any non-HTTP value as a command here.
|
||||
if stdio_mcp_enabled() and is_stdio(trimmed):
|
||||
try:
|
||||
parts = parse_stdio_command(trimmed)
|
||||
|
|
@ -63,7 +63,7 @@ def _validate_url(url: str) -> str:
|
|||
raise HTTPException(status_code = 400, detail = "command must not be empty")
|
||||
if "://" in parts[0]:
|
||||
# A URL-scheme first token is a mistyped URL, not a command. Reject
|
||||
# it cleanly instead of exec-ing it (mirrors the frontend check).
|
||||
# cleanly instead of exec-ing it (mirrors the frontend check).
|
||||
raise HTTPException(
|
||||
status_code = 400,
|
||||
detail = "Enter an http(s):// URL, or a local command whose "
|
||||
|
|
@ -87,7 +87,7 @@ def _validate_url(url: str) -> str:
|
|||
|
||||
|
||||
def _normalize_headers(headers: dict[str, str] | None) -> dict[str, str] | None:
|
||||
"""Trim header names, drop empties, coerce values to str. None if nothing left."""
|
||||
"""Trim header names, drop empties, coerce values to str; None if empty."""
|
||||
if not headers:
|
||||
return None
|
||||
out: dict[str, str] = {}
|
||||
|
|
@ -126,7 +126,7 @@ async def create_mcp_server(
|
|||
url = _validate_url(payload.url)
|
||||
headers = _normalize_headers(payload.headers)
|
||||
# OAuth is HTTP-only; force it off for stdio commands so a stale flag can't
|
||||
# push the probe onto the 305s OAuth timeout. Backend is the enforcer.
|
||||
# push the probe onto the 305s OAuth timeout. Backend enforces this.
|
||||
use_oauth = payload.use_oauth and not is_stdio(url)
|
||||
|
||||
server_id = uuid.uuid4().hex[:16]
|
||||
|
|
@ -182,7 +182,7 @@ async def update_mcp_server(
|
|||
if not changes:
|
||||
raise HTTPException(status_code = 400, detail = "No fields to update")
|
||||
# headers == HTTP headers (remote) or env vars (stdio). On a transport-type
|
||||
# switch with no new headers, drop the old ones so env secrets are not
|
||||
# switch with no new headers, drop the old ones so env secrets aren't
|
||||
# re-sent as HTTP headers (or vice versa).
|
||||
if (
|
||||
"url" in changes
|
||||
|
|
@ -190,9 +190,9 @@ async def update_mcp_server(
|
|||
and "headers_json" not in changes
|
||||
):
|
||||
changes["headers_json"] = None
|
||||
# Clear persisted OAuth tokens when the URL changes or OAuth is
|
||||
# disabled; fastmcp keys tokens by URL and would otherwise let a
|
||||
# re-pointed server silently inherit the old account's credentials.
|
||||
# Clear persisted OAuth tokens when the URL changes or OAuth is disabled;
|
||||
# fastmcp keys tokens by URL and would otherwise let a re-pointed server
|
||||
# silently inherit the old account's credentials.
|
||||
if bool(old.get("use_oauth")) and (
|
||||
("url" in changes and changes["url"] != old["url"]) or changes.get("use_oauth") is False
|
||||
):
|
||||
|
|
@ -248,8 +248,8 @@ async def test_mcp_server(
|
|||
payload: McpServerTestRequest, current_subject: str = Depends(get_current_subject)
|
||||
):
|
||||
# URL/header validation must surface as 400 like create/update so the
|
||||
# frontend's create-form pre-flight gets the same error semantics as
|
||||
# the actual save call. Only catch transport/timeout errors below.
|
||||
# frontend's create-form pre-flight gets the same error semantics as the
|
||||
# save call. Only catch transport/timeout errors below.
|
||||
url = _validate_url(payload.url)
|
||||
headers = _normalize_headers(payload.headers)
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""
|
||||
Model Management API routes
|
||||
"""
|
||||
"""Model management API routes."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
|
|
@ -28,14 +26,13 @@ def _is_valid_repo_id(repo_id: str) -> bool:
|
|||
|
||||
|
||||
def _safe_is_dir(path) -> bool:
|
||||
"""``Path.is_dir()`` that returns ``False`` instead of raising.
|
||||
"""``Path.is_dir()`` returning ``False`` instead of raising.
|
||||
|
||||
On Python >= 3.12 ``is_dir()``'s ``os.stat`` only suppresses
|
||||
"not found"-class errors and now propagates ``PermissionError``
|
||||
(EACCES); on Python <= 3.11 it returned ``False``. The folder-scan
|
||||
endpoints probe well-known system locations (e.g. a root-owned,
|
||||
mode-700 ``/usr/share/ollama/.ollama/models``) and must treat an
|
||||
un-stat-able path as "not a directory", never 500.
|
||||
Python >= 3.12's ``is_dir()`` propagates ``PermissionError``
|
||||
(EACCES); <= 3.11 returned ``False``. Folder-scan endpoints probe
|
||||
system locations (e.g. root-owned mode-700
|
||||
``/usr/share/ollama/.ollama/models``) and must treat an un-stat-able
|
||||
path as "not a directory", never 500.
|
||||
"""
|
||||
try:
|
||||
return Path(path).is_dir()
|
||||
|
|
@ -43,7 +40,7 @@ def _safe_is_dir(path) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
# Add backend directory to path
|
||||
# Add backend dir to path
|
||||
backend_path = Path(__file__).parent.parent.parent
|
||||
if str(backend_path) not in sys.path:
|
||||
sys.path.insert(0, str(backend_path))
|
||||
|
|
@ -79,7 +76,7 @@ try:
|
|||
resolve_export_dir,
|
||||
)
|
||||
except ImportError:
|
||||
# Fallback: try to import from parent directory
|
||||
# Fallback: 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))
|
||||
|
|
@ -167,15 +164,14 @@ def _resolve_hf_cache_dir() -> Path:
|
|||
def _is_model_directory(d: Path) -> bool:
|
||||
"""Return ``True`` when *d* looks like a model directory.
|
||||
|
||||
A model directory must have **both** a config file (``config.json`` or
|
||||
``adapter_config.json``) **and** actual model weight files. Both
|
||||
conditions are required: a bare directory with only loose ``.gguf``
|
||||
files (no config) might be a mixed collection, and a ``config.json``
|
||||
alone (no weights) is not a model directory.
|
||||
Requires **both** a config (``config.json`` or
|
||||
``adapter_config.json``) **and** weight files: loose ``.gguf`` files
|
||||
without config may be a mixed collection, and config without weights
|
||||
is not a model dir.
|
||||
|
||||
Excludes ``mmproj`` GGUF files (vision projectors) and non-weight
|
||||
``.bin`` files (``tokenizer.bin``, ``vocab.bin``, etc.) from the
|
||||
weight check to avoid false positives.
|
||||
``.bin`` files (``tokenizer.bin``, ``vocab.bin``, etc.) to avoid
|
||||
false positives.
|
||||
"""
|
||||
|
||||
def _is_weight_file(f: Path) -> bool:
|
||||
|
|
@ -239,8 +235,8 @@ def _scan_models_dir(models_dir: Path, *, limit: int | None = None) -> List[Loca
|
|||
or any(child.glob("*.gguf"))
|
||||
)
|
||||
except OSError:
|
||||
# Skip individual children that are unreadable (permissions, broken
|
||||
# symlinks, etc.) rather than failing the entire scan.
|
||||
# Skip unreadable children (permissions, broken symlinks)
|
||||
# rather than failing the whole scan.
|
||||
continue
|
||||
if not has_model_files:
|
||||
continue
|
||||
|
|
@ -257,7 +253,7 @@ def _scan_models_dir(models_dir: Path, *, limit: int | None = None) -> List[Loca
|
|||
updated_at = updated_at,
|
||||
),
|
||||
)
|
||||
# Also scan for standalone .gguf files directly in the models directory
|
||||
# Also scan standalone .gguf files in the models directory
|
||||
if limit is None or len(found) < limit:
|
||||
for gguf_file in models_dir.glob("*.gguf"):
|
||||
if limit is not None and len(found) >= limit:
|
||||
|
|
@ -315,16 +311,16 @@ def _scan_hf_cache(cache_dir: Path) -> List[LocalModelInfo]:
|
|||
def _scan_lmstudio_dir(lm_dir: Path) -> List[LocalModelInfo]:
|
||||
"""Scan an LM Studio models directory for model files.
|
||||
|
||||
LM Studio uses a ``publisher/model-name`` folder structure containing
|
||||
GGUF files, or standalone GGUF files at the top level.
|
||||
LM Studio uses a ``publisher/model-name`` folder structure with GGUF
|
||||
files, or standalone GGUF files at the top level.
|
||||
"""
|
||||
if not lm_dir.exists() or not lm_dir.is_dir():
|
||||
return []
|
||||
|
||||
# If the directory itself is a model directory (has config AND weight
|
||||
# files), it is not an LM Studio publisher structure -- return it as a
|
||||
# single model entry. We cannot skip it silently because this function
|
||||
# is the only scanner called for default LM Studio roots.
|
||||
# If lm_dir is itself a model directory (config AND weights), it's
|
||||
# not an LM Studio publisher structure -- return it as a single
|
||||
# entry. Can't skip it silently: this is the only scanner called for
|
||||
# default LM Studio roots.
|
||||
if _is_model_directory(lm_dir):
|
||||
try:
|
||||
updated_at = lm_dir.stat().st_mtime
|
||||
|
|
@ -360,9 +356,9 @@ def _scan_lmstudio_dir(lm_dir: Path) -> List[LocalModelInfo]:
|
|||
)
|
||||
continue
|
||||
|
||||
# If the child directory itself looks like a model directory
|
||||
# (has config AND weight files), surface it directly instead
|
||||
# of descending into it as a publisher.
|
||||
# If the child looks like a model directory (config AND
|
||||
# weights), surface it directly instead of descending into
|
||||
# it as a publisher.
|
||||
if _is_model_directory(child):
|
||||
try:
|
||||
updated_at = child.stat().st_mtime
|
||||
|
|
@ -379,7 +375,7 @@ def _scan_lmstudio_dir(lm_dir: Path) -> List[LocalModelInfo]:
|
|||
)
|
||||
continue
|
||||
|
||||
# child is a publisher directory -- scan its sub-directories
|
||||
# child is a publisher directory -- scan its subdirectories
|
||||
for model_dir in child.iterdir():
|
||||
try:
|
||||
if model_dir.is_dir():
|
||||
|
|
@ -430,11 +426,11 @@ def _scan_lmstudio_dir(lm_dir: Path) -> List[LocalModelInfo]:
|
|||
def _ollama_links_dir(ollama_dir: Path) -> Optional[Path]:
|
||||
"""Return a writable directory for Ollama ``.gguf`` symlinks.
|
||||
|
||||
Prefers ``<ollama_dir>/.studio_links/`` so the links sit next to the
|
||||
blobs they point at. Falls back to a per-ollama-dir namespace under
|
||||
Studio's own cache when the models directory is read-only (common
|
||||
for system installs under ``/usr/share/ollama`` or ``/var/lib/ollama``)
|
||||
so we still surface Ollama models in those environments.
|
||||
Prefers ``<ollama_dir>/.studio_links/`` so links sit next to their
|
||||
blobs. Falls back to a per-ollama-dir namespace under Studio's cache
|
||||
when the models dir is read-only (common for system installs under
|
||||
``/usr/share/ollama`` or ``/var/lib/ollama``) so Ollama models still
|
||||
surface there.
|
||||
"""
|
||||
from utils.paths.storage_roots import cache_root
|
||||
|
||||
|
|
@ -449,9 +445,8 @@ def _ollama_links_dir(ollama_dir: Path) -> Optional[Path]:
|
|||
e,
|
||||
)
|
||||
|
||||
# Fallback: namespace by a hash of the ollama_dir so two different
|
||||
# Ollama roots don't collide. This is a cache path, not a security
|
||||
# boundary.
|
||||
# Fallback: namespace by a hash of ollama_dir so two Ollama roots
|
||||
# don't collide. Cache path, not a security boundary.
|
||||
try:
|
||||
digest = hashlib.sha256(str(ollama_dir.resolve()).encode()).hexdigest()[:12]
|
||||
except OSError:
|
||||
|
|
@ -472,34 +467,29 @@ def _ollama_links_dir(ollama_dir: Path) -> Optional[Path]:
|
|||
def _scan_ollama_dir(ollama_dir: Path, limit: Optional[int] = None) -> List[LocalModelInfo]:
|
||||
"""Scan an Ollama models directory for downloaded models.
|
||||
|
||||
Ollama stores models in a content-addressable layout::
|
||||
Ollama uses a content-addressable layout::
|
||||
|
||||
<ollama_dir>/manifests/<host>/<namespace>/<model>/<tag>
|
||||
<ollama_dir>/blobs/sha256-...
|
||||
|
||||
The default host is ``registry.ollama.ai`` with namespace
|
||||
``library`` (official models), but users can pull from custom
|
||||
namespaces (``mradermacher/llama3``) or entirely different hosts
|
||||
(``hf.co/org/repo:tag``). We iterate all manifest files via
|
||||
``rglob`` so every layout depth is discovered.
|
||||
Default host is ``registry.ollama.ai`` namespace ``library``
|
||||
(official), but users can pull from custom namespaces
|
||||
(``mradermacher/llama3``) or other hosts (``hf.co/org/repo:tag``).
|
||||
We ``rglob`` all manifest files so every layout depth is found.
|
||||
|
||||
Each manifest is JSON with a ``layers`` array. The layer with
|
||||
``mediaType == "application/vnd.ollama.image.model"`` contains the
|
||||
GGUF weights. Vision models also have a projector layer
|
||||
(``application/vnd.ollama.image.projector``). We read the config
|
||||
layer to extract family/size info.
|
||||
Each manifest is JSON with a ``layers`` array. The
|
||||
``application/vnd.ollama.image.model`` layer holds the GGUF weights;
|
||||
vision models add an ``application/vnd.ollama.image.projector``
|
||||
layer. We read the config layer for family/size info.
|
||||
|
||||
Since Ollama blobs lack a ``.gguf`` extension (which the GGUF
|
||||
loading pipeline requires), we create ``.gguf``-named links
|
||||
pointing at the blobs so the existing ``detect_gguf_model`` and
|
||||
``llama-server -m`` paths work unchanged. Each model gets its
|
||||
own subdirectory under the links dir (keyed by a short hash of
|
||||
the manifest path) so that ``detect_mmproj_file`` only sees the
|
||||
projector for *that* model. Links are created as symlinks when
|
||||
possible, falling back to hardlinks (Windows without Developer
|
||||
Mode) as a last resort. The link dir lives under
|
||||
``<ollama_dir>/.studio_links/`` when writable, otherwise under
|
||||
Studio's own cache directory.
|
||||
Ollama blobs lack the ``.gguf`` extension the loading pipeline
|
||||
requires, so we create ``.gguf``-named links to them so
|
||||
``detect_gguf_model`` and ``llama-server -m`` work unchanged. Each
|
||||
model gets its own subdir under the links dir (keyed by a short hash
|
||||
of the manifest path) so ``detect_mmproj_file`` only sees that
|
||||
model's projector. Links are symlinks when possible, falling back to
|
||||
hardlinks (Windows without Developer Mode). The link dir is
|
||||
``<ollama_dir>/.studio_links/`` when writable, else Studio's cache.
|
||||
"""
|
||||
manifests_root = ollama_dir / "manifests"
|
||||
if not manifests_root.is_dir():
|
||||
|
|
@ -518,10 +508,9 @@ def _scan_ollama_dir(ollama_dir: Path, limit: Optional[int] = None) -> List[Loca
|
|||
def _make_link(link_dir: Path, link_name: str, target: Path) -> Optional[str]:
|
||||
"""Create a .gguf-named link to an Ollama blob.
|
||||
|
||||
Tries symlink first, then hardlink (works on Windows without
|
||||
Developer Mode when target is on the same filesystem). Skips
|
||||
the model if neither works -- a full file copy of a multi-GB
|
||||
GGUF inside a synchronous API request would block the backend.
|
||||
Tries symlink, then hardlink (Windows without Developer Mode,
|
||||
same filesystem). Skips the model if neither works -- a full
|
||||
multi-GB copy in a sync API request would block the backend.
|
||||
|
||||
Idempotent: skips recreation when a valid link already exists.
|
||||
"""
|
||||
|
|
@ -529,9 +518,9 @@ def _scan_ollama_dir(ollama_dir: Path, limit: Optional[int] = None) -> List[Loca
|
|||
link_path = link_dir / link_name
|
||||
resolved = target.resolve()
|
||||
|
||||
# Skip if the link already points at the exact same blob.
|
||||
# Only use samefile -- size-based checks can reuse stale links
|
||||
# after `ollama pull` updates a tag to a same-sized blob.
|
||||
# Skip if the link already points at the same blob. Use samefile
|
||||
# only -- size checks can reuse stale links after `ollama pull`
|
||||
# updates a tag to a same-sized blob.
|
||||
try:
|
||||
if link_path.exists() and os.path.samefile(str(link_path), str(resolved)):
|
||||
return str(link_path)
|
||||
|
|
@ -700,9 +689,9 @@ async def list_local_models(
|
|||
hf_default = hf_default_cache_dir()
|
||||
lm_dirs = lmstudio_model_dirs()
|
||||
|
||||
# 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.
|
||||
# Validate models_dir against an allowlist of trusted dirs. Only the
|
||||
# trusted Path objects are used for FS access; the user string is
|
||||
# only used for matching, never for path construction.
|
||||
allowed_roots: list[Path] = [Path("./models").resolve(), hf_cache_dir]
|
||||
if legacy_hf.is_dir():
|
||||
allowed_roots.append(legacy_hf)
|
||||
|
|
@ -719,7 +708,7 @@ async def list_local_models(
|
|||
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
|
||||
models_root = root # trusted root, not the user-supplied path
|
||||
break
|
||||
if models_root is None:
|
||||
raise HTTPException(
|
||||
|
|
@ -734,7 +723,7 @@ async def list_local_models(
|
|||
if legacy_hf.is_dir() and legacy_hf.resolve() != hf_cache_dir.resolve():
|
||||
local_models += _scan_hf_cache(legacy_hf)
|
||||
|
||||
# Scan HF system default cache (may differ when env vars are overridden)
|
||||
# Scan HF system default cache (may differ under env overrides)
|
||||
if (
|
||||
hf_default.is_dir()
|
||||
and hf_default.resolve() != hf_cache_dir.resolve()
|
||||
|
|
@ -746,7 +735,7 @@ async def list_local_models(
|
|||
for lm_dir in lm_dirs:
|
||||
local_models += _scan_lmstudio_dir(lm_dir)
|
||||
|
||||
# Scan user-added custom folders (cap per-folder to avoid unbounded scans)
|
||||
# Scan user-added custom folders (per-folder cap)
|
||||
from storage.studio_db import list_scan_folders
|
||||
|
||||
_MAX_MODELS_PER_FOLDER = 200
|
||||
|
|
@ -758,9 +747,9 @@ async def list_local_models(
|
|||
for folder in custom_folders:
|
||||
folder_path = Path(folder["path"])
|
||||
try:
|
||||
# Ollama scanner creates .studio_links/ with .gguf symlinks.
|
||||
# Filter those from the generic scanners to avoid duplicates
|
||||
# and leaking internal paths into the UI.
|
||||
# Ollama scanner creates .studio_links/ with .gguf
|
||||
# symlinks. Filter those from generic scanners to avoid
|
||||
# duplicates and leaking internal paths into the UI.
|
||||
_generic = [
|
||||
m
|
||||
for m in (
|
||||
|
|
@ -781,10 +770,10 @@ async def list_local_models(
|
|||
continue
|
||||
local_models += [m.model_copy(update = {"source": "custom"}) for m in custom_models]
|
||||
|
||||
# Deduplicate models, but always keep custom folder entries so they
|
||||
# appear in the "Custom Folders" UI section even when the same model
|
||||
# also exists in the HF cache or default models directory. Use a
|
||||
# (id, source) key for custom entries to avoid collisions.
|
||||
# Deduplicate, but always keep custom folder entries so they show
|
||||
# in the "Custom Folders" UI section even when the same model is
|
||||
# also in the HF cache or default models dir. Custom entries use
|
||||
# an (id, source) key to avoid collisions.
|
||||
deduped: dict[str, LocalModelInfo] = {}
|
||||
for model in local_models:
|
||||
key = f"{model.id}\x00custom" if model.source == "custom" else model.id
|
||||
|
|
@ -855,11 +844,10 @@ async def remove_scan_folder_endpoint(
|
|||
async def get_recommended_folders(current_subject: str = Depends(get_current_subject)):
|
||||
"""Return well-known model directories that exist on this machine.
|
||||
|
||||
Lightweight alternative to ``browse-folders`` for showing quick-pick
|
||||
chips without the overhead of enumerating a directory tree. Returns
|
||||
paths that actually exist on disk (HF cache, LM Studio, Ollama,
|
||||
``~/models``, etc.) so the frontend can offer them as one-click
|
||||
"Recommended" shortcuts in the Custom Folders section.
|
||||
Lightweight alternative to ``browse-folders`` for quick-pick chips
|
||||
without enumerating a directory tree. Returns paths that exist on
|
||||
disk (HF cache, LM Studio, Ollama, ``~/models``, etc.) for the
|
||||
frontend's one-click "Recommended" shortcuts.
|
||||
"""
|
||||
from utils.paths.storage_roots import lmstudio_model_dirs
|
||||
|
||||
|
|
@ -900,26 +888,25 @@ async def get_recommended_folders(current_subject: str = Depends(get_current_sub
|
|||
return {"folders": folders}
|
||||
|
||||
|
||||
# Heuristic ceiling on how many children to stat when checking whether a
|
||||
# directory "looks like" it contains models. Keeps the browser snappy
|
||||
# even when a directory has thousands of unrelated entries.
|
||||
# Max children to stat when checking if a directory "looks like" it
|
||||
# holds models. Keeps the browser snappy on dirs with thousands of
|
||||
# unrelated entries.
|
||||
_BROWSE_MODEL_HINT_PROBE = 64
|
||||
# Hard cap on how many subdirectory entries we send back. Pointing the
|
||||
# browser at something like ``/usr/lib`` or ``/proc`` must not stat-storm
|
||||
# the process or send tens of thousands of rows to the client.
|
||||
# Hard cap on subdirectory entries returned. Browsing ``/usr/lib`` or
|
||||
# ``/proc`` must not stat-storm the process or send tens of thousands of
|
||||
# rows to the client.
|
||||
_BROWSE_ENTRY_CAP = 2000
|
||||
|
||||
|
||||
def _count_model_files(directory: Path, cap: int = 200) -> int:
|
||||
"""Count GGUF/safetensors files immediately inside *directory*.
|
||||
Used to surface a count-hint on the response so the UI can tell
|
||||
users that a leaf directory (no subdirs, only weights) is a valid
|
||||
"Use this folder" target.
|
||||
|
||||
Bounded by *visited entries*, not by *match count*: in directories
|
||||
with many non-model files (or many subdirectories) the scan still
|
||||
stops after ``cap`` entries so a UI hint never costs more than a
|
||||
bounded directory walk.
|
||||
Surfaces a count-hint so the UI can mark a leaf directory (no
|
||||
subdirs, only weights) as a valid "Use this folder" target.
|
||||
|
||||
Bounded by *visited entries*, not match count: the scan stops after
|
||||
``cap`` entries even in dirs full of non-model files/subdirs, so a UI
|
||||
hint never costs more than a bounded directory walk.
|
||||
"""
|
||||
n = 0
|
||||
visited = 0
|
||||
|
|
@ -945,10 +932,10 @@ def _count_model_files(directory: Path, cap: int = 200) -> int:
|
|||
|
||||
|
||||
def _has_direct_model_signal(directory: Path) -> bool:
|
||||
"""Return True if *directory* has an immediate child that signals
|
||||
it holds a model: a GGUF/safetensors/config.json file, or a
|
||||
`models--*` subdir (HF hub cache). Bounded by
|
||||
``_BROWSE_MODEL_HINT_PROBE`` to stay fast."""
|
||||
"""Return True if *directory* has an immediate child signalling a
|
||||
model: a GGUF/safetensors/config.json file, or a `models--*` subdir
|
||||
(HF hub cache). Bounded by ``_BROWSE_MODEL_HINT_PROBE`` to stay
|
||||
fast."""
|
||||
try:
|
||||
it = directory.iterdir()
|
||||
except OSError:
|
||||
|
|
@ -975,23 +962,22 @@ def _has_direct_model_signal(directory: Path) -> bool:
|
|||
|
||||
|
||||
def _looks_like_model_dir(directory: Path) -> bool:
|
||||
"""Bounded heuristic used by the folder browser to flag directories
|
||||
worth exploring. False negatives are fine; the real scanner is
|
||||
"""Bounded heuristic to flag directories worth exploring in the
|
||||
folder browser. False negatives are fine; the real scanner is
|
||||
authoritative.
|
||||
|
||||
Three signals, cheapest first:
|
||||
|
||||
1. Directory name itself: ``models--*`` is the HuggingFace hub cache
|
||||
layout (``blobs``/``refs``/``snapshots`` children wouldn't match
|
||||
the file-level probes below).
|
||||
2. An immediate child is a weight file or config (handled by
|
||||
1. Directory name ``models--*`` (HuggingFace hub cache layout; its
|
||||
``blobs``/``refs``/``snapshots`` children wouldn't match the
|
||||
file-level probes below).
|
||||
2. An immediate child is a weight file or config (via
|
||||
:func:`_has_direct_model_signal`).
|
||||
3. A grandchild has a direct signal -- this catches the
|
||||
``publisher/model/weights.gguf`` layout used by LM Studio and
|
||||
Ollama. We probe at most the first
|
||||
``_BROWSE_MODEL_HINT_PROBE`` child directories, each of which is
|
||||
checked with a bounded :func:`_has_direct_model_signal` call,
|
||||
so the total cost stays O(PROBE^2) worst-case.
|
||||
3. A grandchild has a direct signal -- catches the
|
||||
``publisher/model/weights.gguf`` layout of LM Studio and Ollama.
|
||||
Probes at most the first ``_BROWSE_MODEL_HINT_PROBE`` child dirs,
|
||||
each via a bounded :func:`_has_direct_model_signal`, so total cost
|
||||
is O(PROBE^2) worst-case.
|
||||
"""
|
||||
if directory.name.startswith("models--"):
|
||||
return True
|
||||
|
|
@ -1011,7 +997,7 @@ def _looks_like_model_dir(directory: Path) -> bool:
|
|||
continue
|
||||
except OSError:
|
||||
continue
|
||||
# Fast name check first
|
||||
# Fast name check first.
|
||||
if child.name.startswith("models--"):
|
||||
return True
|
||||
if _has_direct_model_signal(child):
|
||||
|
|
@ -1022,16 +1008,15 @@ def _looks_like_model_dir(directory: Path) -> bool:
|
|||
|
||||
|
||||
def _build_browse_allowlist() -> list[Path]:
|
||||
"""Return the list of root directories the folder browser is allowed
|
||||
to walk. The same list is used to seed the sidebar suggestion chips,
|
||||
so chip targets are always reachable.
|
||||
"""Return the root directories the folder browser may walk. The same
|
||||
list seeds the sidebar suggestion chips, so chip targets are always
|
||||
reachable.
|
||||
|
||||
Roots include the current user's HOME, the resolved HF cache dirs,
|
||||
Studio's own outputs/exports/studio root, registered scan folders,
|
||||
and well-known third-party local-LLM dirs (LM Studio, Ollama,
|
||||
`~/models`). Each is added only if it currently resolves to a real
|
||||
directory, so we never produce a "dead" sandbox boundary the user
|
||||
can't navigate into.
|
||||
Roots: the user's HOME, resolved HF cache dirs, Studio's
|
||||
outputs/exports/studio root, registered scan folders, and well-known
|
||||
third-party local-LLM dirs (LM Studio, Ollama, `~/models`). Each is
|
||||
added only if it resolves to a real directory, so we never produce a
|
||||
"dead" sandbox boundary.
|
||||
"""
|
||||
from utils.paths import (
|
||||
hf_default_cache_dir,
|
||||
|
|
@ -1100,9 +1085,8 @@ def _build_browse_allowlist() -> list[Path]:
|
|||
|
||||
|
||||
def _is_path_inside_allowlist(target: Path, allowed_roots: list[Path]) -> bool:
|
||||
"""Return True if *target* equals or is a descendant of any allowed
|
||||
root. The comparison uses ``os.path.realpath`` so symlinks cannot be
|
||||
used to escape the sandbox.
|
||||
"""Return True if *target* equals or descends from any allowed root.
|
||||
Uses ``os.path.realpath`` so symlinks can't escape the sandbox.
|
||||
"""
|
||||
try:
|
||||
target_real = os.path.realpath(str(target))
|
||||
|
|
@ -1262,30 +1246,28 @@ async def browse_folders(
|
|||
"""
|
||||
List immediate subdirectories of *path* for the Custom Folders picker.
|
||||
|
||||
The frontend uses this to render a modal folder browser without needing
|
||||
a native OS dialog (Studio is served over HTTP, so the browser can't
|
||||
reveal absolute paths on the host). The endpoint is read-only and does
|
||||
not create, move, or delete anything. It simply enumerates visible
|
||||
subdirectories so the user can click their way to a folder and hand
|
||||
the resulting string back to POST `/api/models/scan-folders`.
|
||||
Lets the frontend render a modal folder browser without a native OS
|
||||
dialog (Studio is served over HTTP, so the browser can't reveal host
|
||||
paths). Read-only: it only enumerates visible subdirectories so the
|
||||
user can click to a folder and hand the string back to POST
|
||||
`/api/models/scan-folders`.
|
||||
|
||||
Sandbox: requests are bounded to the allowlist returned by
|
||||
Sandbox: bounded to the allowlist from
|
||||
:func:`_build_browse_allowlist` (HOME, HF cache, Studio dirs,
|
||||
registered scan folders, well-known model dirs). Paths outside the
|
||||
allowlist return 403 so users cannot probe ``/etc``, ``/proc``,
|
||||
``/root`` (when not HOME), or other sensitive system locations
|
||||
even if the server process can read them. Symlinks are resolved
|
||||
via ``os.path.realpath`` before the check, so symlink traversal
|
||||
cannot escape the sandbox either.
|
||||
registered scan folders, well-known model dirs). Paths outside it
|
||||
return 403 so users can't probe ``/etc``, ``/proc``, ``/root`` (when
|
||||
not HOME), or other sensitive locations even if the process can read
|
||||
them. Symlinks are resolved via ``os.path.realpath`` first, so
|
||||
traversal can't escape the sandbox.
|
||||
|
||||
Sorting: directories that look like they hold models come first, then
|
||||
plain directories, then hidden entries (if `show_hidden=true`).
|
||||
Sorting: model-bearing dirs first, then plain dirs, then hidden
|
||||
entries (if `show_hidden=true`).
|
||||
"""
|
||||
from utils.paths import hf_default_cache_dir, well_known_model_dirs
|
||||
from storage.studio_db import list_scan_folders
|
||||
|
||||
# Build the allowlist once -- both the sandbox check below and the
|
||||
# suggestion chips use the same set, so chips are always navigable.
|
||||
# Build the allowlist once -- the sandbox check and suggestion chips
|
||||
# share it, so chips are always navigable.
|
||||
allowed_roots = _build_browse_allowlist()
|
||||
|
||||
try:
|
||||
|
|
@ -1301,7 +1283,7 @@ async def browse_folders(
|
|||
raise
|
||||
|
||||
# Enumerate immediate subdirectories with a bounded cap so a stray
|
||||
# query against ``/usr/lib`` or ``/proc`` can't stat-storm the process.
|
||||
# query against ``/usr/lib`` or ``/proc`` can't stat-storm us.
|
||||
entries: list[BrowseEntry] = []
|
||||
truncated = False
|
||||
visited = 0
|
||||
|
|
@ -1321,13 +1303,12 @@ async def browse_folders(
|
|||
|
||||
try:
|
||||
for child in it:
|
||||
# Bound by *visited entries*, not by *appended entries*: in
|
||||
# directories full of files (or hidden subdirs when
|
||||
# ``show_hidden=False``) the cap on ``len(entries)`` would
|
||||
# never trigger and we'd still stat every child. Counting
|
||||
# visits keeps the worst-case work to ``_BROWSE_ENTRY_CAP``
|
||||
# iterdir/is_dir calls regardless of how many of them
|
||||
# survive the filters below.
|
||||
# Bound by *visited*, not *appended*: in dirs full of files
|
||||
# (or hidden subdirs when ``show_hidden=False``) a cap on
|
||||
# ``len(entries)`` would never trigger and we'd stat every
|
||||
# child. Counting visits caps worst-case work at
|
||||
# ``_BROWSE_ENTRY_CAP`` iterdir/is_dir calls regardless of
|
||||
# how many survive the filters below.
|
||||
visited += 1
|
||||
if visited > _BROWSE_ENTRY_CAP:
|
||||
truncated = True
|
||||
|
|
@ -1366,10 +1347,10 @@ async def browse_folders(
|
|||
|
||||
entries.sort(key = _sort_key)
|
||||
|
||||
# Parent is None at the filesystem root (`p.parent == p`) AND when
|
||||
# the parent would step outside the sandbox -- otherwise the up-row
|
||||
# would 403 on click. Users can still hop to other allowed roots
|
||||
# via the suggestion chips below.
|
||||
# Parent is None at the filesystem root (`p.parent == p`) and when
|
||||
# the parent would leave the sandbox -- otherwise the up-row would
|
||||
# 403 on click. Users can still hop to other allowed roots via the
|
||||
# suggestion chips below.
|
||||
parent: Optional[str]
|
||||
if target.parent == target or not _is_path_inside_allowlist(target.parent, allowed_roots):
|
||||
parent = None
|
||||
|
|
@ -1393,27 +1374,25 @@ async def browse_folders(
|
|||
seen_sug.add(resolved)
|
||||
suggestions.append(resolved)
|
||||
|
||||
# Home always comes first -- it's the safe fallback when everything
|
||||
# else is cold.
|
||||
# Home first -- the safe fallback when everything else is cold.
|
||||
_add_sug(Path.home())
|
||||
# The HF cache root the process is actually using.
|
||||
try:
|
||||
_add_sug(hf_default_cache_dir())
|
||||
except Exception:
|
||||
pass
|
||||
# Already-registered scan folders (what the user has curated).
|
||||
# Already-registered scan folders (user-curated).
|
||||
try:
|
||||
for folder in list_scan_folders():
|
||||
_add_sug(Path(folder.get("path", "")))
|
||||
except Exception as exc:
|
||||
logger.debug("browse-folders: could not load scan folders: %s", exc)
|
||||
# Directories commonly used by other local-LLM tools: LM Studio
|
||||
# (`~/.lmstudio/models` + legacy `~/.cache/lm-studio/models` +
|
||||
# user-configured downloadsFolder from LM Studio's settings.json),
|
||||
# Ollama (`~/.ollama/models` + common system paths + OLLAMA_MODELS
|
||||
# env var), and generic user-choice spots (`~/models`, `~/Models`).
|
||||
# Each helper only returns paths that currently exist so we never
|
||||
# show dead chips.
|
||||
# Dirs used by other local-LLM tools: LM Studio
|
||||
# (`~/.lmstudio/models` + legacy `~/.cache/lm-studio/models` + LM
|
||||
# Studio settings.json downloadsFolder), Ollama (`~/.ollama/models`
|
||||
# + common system paths + OLLAMA_MODELS env var), and generic spots
|
||||
# (`~/models`, `~/Models`). Each helper returns only existing paths
|
||||
# so we never show dead chips.
|
||||
try:
|
||||
for p in well_known_model_dirs():
|
||||
_add_sug(p)
|
||||
|
|
@ -1441,18 +1420,12 @@ def _looks_like_mlx_repo(model_id: str) -> bool:
|
|||
|
||||
@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.
|
||||
"""
|
||||
"""List available models: default plus currently loaded."""
|
||||
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)
|
||||
|
|
@ -1470,7 +1443,7 @@ async def list_models(current_subject: str = Depends(get_current_subject)):
|
|||
)
|
||||
loaded_models.append(model_info)
|
||||
|
||||
# Include active GGUF model (loaded via llama-server)
|
||||
# Include active GGUF model (loaded via llama-server).
|
||||
from routes.inference import get_llama_cpp_backend
|
||||
|
||||
llama_backend = get_llama_cpp_backend()
|
||||
|
|
@ -1486,12 +1459,11 @@ async def list_models(current_subject: str = Depends(get_current_subject)):
|
|||
)
|
||||
)
|
||||
|
||||
# Combine default and loaded models
|
||||
# Combine default and loaded models.
|
||||
all_models = []
|
||||
seen_ids = set()
|
||||
|
||||
# Prefer loaded entries for duplicate ids so runtime flags
|
||||
# (is_mlx, is_vision, is_audio, ...) are not lost.
|
||||
# Prefer loaded entries for duplicate ids so runtime flags survive.
|
||||
loaded_by_id = {model_info.id: model_info for model_info in loaded_models}
|
||||
|
||||
# Add default models
|
||||
|
|
@ -1506,7 +1478,7 @@ async def list_models(current_subject: str = Depends(get_current_subject)):
|
|||
all_models.append(model_info)
|
||||
seen_ids.add(model_id)
|
||||
|
||||
# Add loaded models
|
||||
# Add loaded models.
|
||||
for model_info in loaded_models:
|
||||
if model_info.id not in seen_ids:
|
||||
all_models.append(model_info)
|
||||
|
|
@ -1525,7 +1497,7 @@ async def list_models(current_subject: str = Depends(get_current_subject)):
|
|||
|
||||
|
||||
def _get_max_position_embeddings(config) -> Optional[int]:
|
||||
"""Extract max_position_embeddings from a model config, checking text_config fallback."""
|
||||
"""Extract max_position_embeddings from a config, with 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"):
|
||||
|
|
@ -1534,7 +1506,7 @@ def _get_max_position_embeddings(config) -> Optional[int]:
|
|||
|
||||
|
||||
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."""
|
||||
"""Total size of model weight files from HF Hub."""
|
||||
try:
|
||||
from huggingface_hub import HfApi
|
||||
|
||||
|
|
@ -1562,11 +1534,7 @@ async def get_model_config(
|
|||
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.
|
||||
"""
|
||||
"""Get configuration for a specific model (wraps load_model_defaults)."""
|
||||
try:
|
||||
if not is_local_path(model_name):
|
||||
resolved = resolve_cached_repo_id_case(model_name)
|
||||
|
|
@ -1581,15 +1549,14 @@ async def get_model_config(
|
|||
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)
|
||||
# Detect capabilities (pass HF token for gated models).
|
||||
is_vision = is_vision_model(model_name, hf_token = hf_token)
|
||||
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
|
||||
# Check if it's a LoRA adapter.
|
||||
is_lora = False
|
||||
base_model = None
|
||||
max_position_embeddings = None
|
||||
|
|
@ -1601,7 +1568,7 @@ async def get_model_config(
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback: try AutoConfig directly if not found yet
|
||||
# Fallback: try AutoConfig directly if not found yet.
|
||||
if max_position_embeddings is None:
|
||||
try:
|
||||
from transformers import AutoConfig as _AutoConfig
|
||||
|
|
@ -1653,18 +1620,17 @@ async def scan_loras(
|
|||
),
|
||||
current_subject: str = Depends(get_current_subject),
|
||||
):
|
||||
"""
|
||||
Scan for trained LoRA adapters and exported models.
|
||||
"""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.
|
||||
Returns training outputs (outputs_dir) and exported models
|
||||
(exports_dir) in one list, distinguished by the 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
|
||||
# Scan training outputs.
|
||||
trained_models = scan_trained_models(outputs_dir = resolved_outputs_dir)
|
||||
for display_name, model_path, model_type in trained_models:
|
||||
base_model = get_base_model_from_checkpoint(model_path)
|
||||
|
|
@ -1712,7 +1678,7 @@ def _is_path_under(path: Path, root: Path) -> bool:
|
|||
|
||||
|
||||
def _is_path_under_lexically(path: Path, root: Path) -> bool:
|
||||
"""Check containment without resolving the final path's symlink target."""
|
||||
"""Check containment without resolving the final path's symlink."""
|
||||
try:
|
||||
absolute_path = Path(os.path.abspath(str(path)))
|
||||
absolute_root = Path(os.path.abspath(str(root)))
|
||||
|
|
@ -1744,10 +1710,10 @@ def _loading_model_matches_deleted_path(loading_model: object, deleted_path: Pat
|
|||
|
||||
|
||||
def _prune_empty_parents(start: Path, stop_at: Path) -> None:
|
||||
"""Remove empty ancestor directories of ``start`` up to (but not including) ``stop_at``.
|
||||
"""Remove empty ancestors of ``start`` up to (not including) ``stop_at``.
|
||||
|
||||
Used after deleting a model checkpoint so the enclosing run directory does
|
||||
not linger as an empty entry in scan results.
|
||||
Used after deleting a checkpoint so the enclosing run dir doesn't
|
||||
linger as an empty entry in scan results.
|
||||
"""
|
||||
try:
|
||||
stop_resolved = stop_at.resolve()
|
||||
|
|
@ -1799,8 +1765,8 @@ async def delete_finetuned_model(
|
|||
):
|
||||
"""Delete a Studio-trained or exported model from disk.
|
||||
|
||||
Only paths under Studio's outputs/exports roots are accepted. Exported
|
||||
GGUF entries can delete one quantization variant at a time.
|
||||
Only paths under Studio's outputs/exports roots are accepted.
|
||||
Exported GGUF entries can delete one quant variant at a time.
|
||||
"""
|
||||
if source not in {"training", "exported"}:
|
||||
raise HTTPException(
|
||||
|
|
@ -2126,18 +2092,16 @@ async def get_gguf_variants(
|
|||
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
|
||||
or a local directory (e.g. LM Studio model folder).
|
||||
"""List GGUF quantization variants for a HuggingFace repo or local
|
||||
directory (e.g. LM Studio model folder).
|
||||
|
||||
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.
|
||||
Returns all variants (Q4_K_M, Q8_0, BF16, etc.) with file sizes,
|
||||
whether the model supports vision, and the recommended default.
|
||||
"""
|
||||
try:
|
||||
from utils.models.model_config import is_local_path, list_local_gguf_variants
|
||||
|
||||
# Local directory path (e.g. LM Studio models) — scan filesystem
|
||||
# Local directory path (e.g. LM Studio models) — scan filesystem.
|
||||
if is_local_path(repo_id):
|
||||
variants, has_vision = list_local_gguf_variants(repo_id)
|
||||
|
||||
|
|
@ -2160,26 +2124,25 @@ async def get_gguf_variants(
|
|||
default_variant = default_variant,
|
||||
)
|
||||
|
||||
# Remote HuggingFace repo — query HF API
|
||||
# Remote HuggingFace repo — query HF API.
|
||||
variants, has_vision = list_gguf_variants(repo_id, hf_token = hf_token)
|
||||
|
||||
# Determine default variant
|
||||
# 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.
|
||||
# Which variants are fully downloaded in the HF cache. For split
|
||||
# GGUFs, ALL shards must be present -- sum cached bytes per
|
||||
# variant vs. the expected total. The HF cache dir uses the
|
||||
# repo_id casing from download time, which may differ from the
|
||||
# canonical repo_id, so match case-insensitively.
|
||||
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
|
||||
# Sanitize repo_id: "owner/name" with safe chars only.
|
||||
if not _is_valid_repo_id(repo_id):
|
||||
raise ValueError(f"Invalid repo_id format: {repo_id}")
|
||||
|
||||
|
|
@ -2203,7 +2166,7 @@ async def get_gguf_variants(
|
|||
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)
|
||||
# Small rounding tolerance (symlinks vs real sizes).
|
||||
return cached >= variant.size_bytes * 0.99
|
||||
|
||||
return GgufVariantsResponse(
|
||||
|
|
@ -2236,10 +2199,10 @@ async def get_gguf_download_progress(
|
|||
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.
|
||||
"""Download progress from cached GGUF files for a specific variant.
|
||||
|
||||
Tracks completed shard downloads in snapshots and in-progress downloads
|
||||
in the blobs directory (incomplete files).
|
||||
Tracks completed shards in snapshots and in-progress (.incomplete)
|
||||
downloads in the blobs directory.
|
||||
"""
|
||||
try:
|
||||
if not _is_valid_repo_id(repo_id):
|
||||
|
|
@ -2258,12 +2221,12 @@ async def get_gguf_download_progress(
|
|||
in_progress_bytes = 0
|
||||
for entry in cache_dir.iterdir():
|
||||
if entry.name.lower() == target:
|
||||
# Count completed .gguf files matching this variant in snapshots
|
||||
# Completed .gguf files for this variant in snapshots.
|
||||
for f in _iter_gguf_paths(entry):
|
||||
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)
|
||||
# In-progress downloads (.incomplete) in blobs.
|
||||
blobs_dir = entry / "blobs"
|
||||
if blobs_dir.is_dir():
|
||||
for f in blobs_dir.iterdir():
|
||||
|
|
@ -2273,7 +2236,7 @@ async def get_gguf_download_progress(
|
|||
|
||||
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)
|
||||
# Report 1.0 only when all bytes are in completed files.
|
||||
if expected_bytes > 0 and downloaded_bytes >= expected_bytes:
|
||||
progress = 1.0
|
||||
return {
|
||||
|
|
@ -2288,9 +2251,9 @@ async def get_gguf_download_progress(
|
|||
def _resolve_hf_cache_realpath(repo_dir: Path) -> Optional[str]:
|
||||
"""Pick the most useful on-disk path for a HF cache repo.
|
||||
|
||||
Prefers the most-recent snapshot dir (what `from_pretrained` actually
|
||||
points at). Falls back to the cache repo root. Returns the resolved
|
||||
realpath so symlinks under snapshots/ are followed back to blobs/.
|
||||
Prefers the most-recent snapshot dir (what `from_pretrained` points
|
||||
at), falling back to the cache repo root. Returns the resolved
|
||||
realpath so symlinks under snapshots/ follow back to blobs/.
|
||||
"""
|
||||
try:
|
||||
snapshots_dir = repo_dir / "snapshots"
|
||||
|
|
@ -2312,11 +2275,10 @@ async def get_download_progress(
|
|||
"""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.
|
||||
Also returns ``cache_path``: the realpath of the snapshot directory
|
||||
(or the cache repo root if no snapshot exists yet) so the UI can
|
||||
show users where the weights actually live on disk.
|
||||
(.incomplete) downloads. Gets the expected total size from the HF API
|
||||
on the first call, then caches it for later polls. Also returns
|
||||
``cache_path``: the realpath of the snapshot dir (or cache repo root
|
||||
if no snapshot yet) so the UI can show where weights live on disk.
|
||||
"""
|
||||
_empty = {
|
||||
"downloaded_bytes": 0,
|
||||
|
|
@ -2356,10 +2318,10 @@ async def get_download_progress(
|
|||
if downloaded_bytes == 0:
|
||||
return {**_empty, "cache_path": cache_path}
|
||||
|
||||
# Get expected size from HF API (cached per repo_id)
|
||||
# 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
|
||||
# Total unknown; report bytes only, no percentage.
|
||||
return {
|
||||
"downloaded_bytes": downloaded_bytes,
|
||||
"expected_bytes": 0,
|
||||
|
|
@ -2367,11 +2329,10 @@ async def get_download_progress(
|
|||
"cache_path": cache_path,
|
||||
}
|
||||
|
||||
# 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.
|
||||
# 95% completion threshold (blob dedup can make completed_bytes
|
||||
# differ slightly from expected_bytes). Do NOT treat "no
|
||||
# .incomplete files" as done -- HF downloads sequentially, so
|
||||
# between files none exist even though it's far from finished.
|
||||
if completed_bytes >= expected_bytes * 0.95:
|
||||
progress = 1.0
|
||||
else:
|
||||
|
|
@ -2406,14 +2367,14 @@ def _get_repo_size_cached(repo_id: str) -> int:
|
|||
|
||||
|
||||
def _all_hf_cache_scans():
|
||||
"""Return scan_cache_dir results for the active, legacy, and default HF caches."""
|
||||
"""scan_cache_dir results for the active, legacy, and default HF caches."""
|
||||
from huggingface_hub import scan_cache_dir
|
||||
from utils.paths import legacy_hf_cache_dir, hf_default_cache_dir
|
||||
|
||||
scans = [scan_cache_dir()]
|
||||
seen: set[str] = set()
|
||||
try:
|
||||
# Resolve the active cache dir so we can dedup
|
||||
# Resolve the active cache dir for deduplication.
|
||||
from huggingface_hub.constants import HF_HUB_CACHE
|
||||
seen.add(str(Path(HF_HUB_CACHE).resolve()))
|
||||
except Exception:
|
||||
|
|
@ -2435,14 +2396,14 @@ def _is_gguf_filename(name: str) -> bool:
|
|||
|
||||
|
||||
def _is_mmproj_filename(name: str) -> bool:
|
||||
"""Match GGUF vision-adapter (mmproj) files. Kept consistent with
|
||||
"""Match GGUF vision-adapter (mmproj) files. Consistent with
|
||||
``utils.models.model_config._is_mmproj``."""
|
||||
return "mmproj" in name.lower()
|
||||
|
||||
|
||||
def _is_main_gguf_filename(name: str) -> bool:
|
||||
"""A GGUF file that is a primary weight artifact, not an mmproj
|
||||
vision adapter."""
|
||||
"""A GGUF file that is a primary weight, not an mmproj vision
|
||||
adapter."""
|
||||
return _is_gguf_filename(name) and not _is_mmproj_filename(name)
|
||||
|
||||
|
||||
|
|
@ -2453,17 +2414,16 @@ def _iter_gguf_paths(root: Path):
|
|||
|
||||
|
||||
def _repo_gguf_size_bytes(repo_info) -> int:
|
||||
"""Return the total on-disk size of primary GGUF weight files across
|
||||
all revisions, excluding mmproj vision-adapter files.
|
||||
"""Total on-disk size of primary GGUF weight files across all
|
||||
revisions, excluding mmproj vision-adapter files.
|
||||
|
||||
Hugging Face hardlinks blobs shared between revisions, so this
|
||||
deduplicates by blob path (or, as a fallback, by revision commit
|
||||
hash + filename) to avoid double-counting the same bytes. Files
|
||||
with an unknown size (``size_on_disk is None``, e.g. a partial or
|
||||
interrupted download) are treated as zero bytes. mmproj files are
|
||||
excluded so that repos whose only ``.gguf`` artifact is a vision
|
||||
adapter are not classified as GGUF repos: the variant selector
|
||||
filters mmproj out and would otherwise show zero pickable variants.
|
||||
deduplicates by blob path (or revision commit hash + filename as a
|
||||
fallback) to avoid double-counting. Unknown sizes (``size_on_disk is
|
||||
None``, e.g. a partial download) count as zero. mmproj files are
|
||||
excluded so repos whose only ``.gguf`` artifact is a vision adapter
|
||||
aren't classed as GGUF repos: the variant selector filters mmproj
|
||||
out and would otherwise show zero pickable variants.
|
||||
"""
|
||||
unique_blobs: dict[str, int] = {}
|
||||
for revision in repo_info.revisions:
|
||||
|
|
@ -2480,9 +2440,9 @@ def _repo_gguf_size_bytes(repo_info) -> int:
|
|||
|
||||
|
||||
def _repo_has_gguf_files(repo_info) -> bool:
|
||||
"""Return True when any revision in a cached repo contains a
|
||||
primary GGUF weight file. Repos whose only ``.gguf`` artifact is
|
||||
an mmproj vision adapter are not treated as GGUF here."""
|
||||
"""True when any revision in a cached repo has a primary GGUF weight
|
||||
file. Repos whose only ``.gguf`` artifact is an mmproj vision adapter
|
||||
are not treated as GGUF here."""
|
||||
return _repo_gguf_size_bytes(repo_info) > 0
|
||||
|
||||
|
||||
|
|
@ -2576,14 +2536,14 @@ async def delete_cached_model(
|
|||
):
|
||||
"""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.
|
||||
With *variant*, only GGUF files matching that quant label are removed
|
||||
(e.g. ``UD-Q4_K_XL``); otherwise the whole 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
|
||||
# Refuse if the model is currently loaded.
|
||||
try:
|
||||
from routes.inference import get_llama_cpp_backend
|
||||
llama_backend = get_llama_cpp_backend()
|
||||
|
|
@ -2641,7 +2601,7 @@ async def delete_cached_model(
|
|||
quant = _extract_quant_label(f.file_name)
|
||||
if quant.lower() != variant.lower():
|
||||
continue
|
||||
# Delete the blob (actual data) and the snapshot symlink
|
||||
# Delete the blob (data) and the snapshot symlink.
|
||||
try:
|
||||
blob = Path(f.blob_path)
|
||||
snap = Path(f.file_path)
|
||||
|
|
@ -2700,8 +2660,7 @@ async def list_checkpoints(
|
|||
),
|
||||
current_subject: str = Depends(get_current_subject),
|
||||
):
|
||||
"""
|
||||
List available checkpoints in the outputs directory.
|
||||
"""List checkpoints in the outputs directory.
|
||||
|
||||
Scans the outputs folder for training runs and their checkpoints.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -4,12 +4,12 @@
|
|||
"""
|
||||
API routes for external LLM provider management.
|
||||
|
||||
Provides endpoints for:
|
||||
- Discovering available provider types (registry)
|
||||
Endpoints:
|
||||
- Discover available provider types (registry)
|
||||
- CRUD for saved provider configurations (no API keys stored)
|
||||
- Fetching the RSA public key for API key encryption
|
||||
- Testing provider connectivity
|
||||
- Listing models from a provider
|
||||
- Fetch the RSA public key for API key encryption
|
||||
- Test provider connectivity
|
||||
- List models from a provider
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
|
@ -54,11 +54,10 @@ router = APIRouter()
|
|||
async def get_public_key(current_subject: str = Depends(get_current_subject)):
|
||||
"""Return the RSA public key PEM for client-side API key encryption.
|
||||
|
||||
The ``fingerprint`` field is a short SHA256 of the PEM and is meant
|
||||
purely for diagnostics — a mismatch between what the frontend
|
||||
captured at encrypt time and what the server reports here is a
|
||||
clear signal that the keypair rotated mid-flight (e.g. the server
|
||||
re-ran ``init_key_pair`` for any reason).
|
||||
The ``fingerprint`` field is a short SHA256 of the PEM, for diagnostics: a
|
||||
mismatch between what the frontend captured at encrypt time and what the
|
||||
server reports here signals the keypair rotated mid-flight (e.g. the server
|
||||
re-ran ``init_key_pair``).
|
||||
"""
|
||||
return {
|
||||
"public_key": get_public_key_pem(),
|
||||
|
|
@ -80,10 +79,10 @@ async def list_registry(current_subject: str = Depends(get_current_subject)):
|
|||
|
||||
@router.get("/pricing")
|
||||
async def get_pricing_snapshot(current_subject: str = Depends(get_current_subject)):
|
||||
"""Static per-MTok pricing table the frontend uses to convert
|
||||
upstream usage chunks into a per-turn USD cost. See
|
||||
``core/inference/pricing.py`` for sourcing notes; values reflect
|
||||
the published prices as of the file's last update."""
|
||||
"""Static per-MTok pricing table the frontend uses to convert upstream
|
||||
usage chunks into a per-turn USD cost. See ``core/inference/pricing.py``
|
||||
for sourcing notes; values reflect the published prices as of the file's
|
||||
last update."""
|
||||
return pricing_snapshot()
|
||||
|
||||
|
||||
|
|
@ -196,7 +195,7 @@ async def test_provider(
|
|||
Test connectivity to an external provider.
|
||||
|
||||
Makes a lightweight GET /models call to verify the API key works.
|
||||
The encrypted_api_key is decrypted server-side and never stored.
|
||||
encrypted_api_key is decrypted server-side and never stored.
|
||||
"""
|
||||
info = get_provider_info(payload.provider_type)
|
||||
if info is None:
|
||||
|
|
@ -267,7 +266,7 @@ async def list_provider_models(
|
|||
"""
|
||||
List models available from an external provider.
|
||||
|
||||
The encrypted_api_key is decrypted server-side and never stored.
|
||||
encrypted_api_key is decrypted server-side and never stored.
|
||||
"""
|
||||
info = get_provider_info(payload.provider_type)
|
||||
if info is None:
|
||||
|
|
@ -308,16 +307,15 @@ async def list_provider_models(
|
|||
|
||||
try:
|
||||
models = await client.list_models()
|
||||
# Registry-level model-id filters are scoped to the canonical
|
||||
# native Gemini base. A custom Gemini OAI-compatible proxy
|
||||
# (LiteLLM, deployment gateway) returns IDs like
|
||||
# `google/gemini-2.5-flash`, `gemini/gemini-2.5-flash`, or
|
||||
# team-prefixed deployment aliases; the native allowlist regex
|
||||
# would strip those out and leave the picker empty even though
|
||||
# the chat path now routes them via the OAI-compatible
|
||||
# dispatcher (the same gate ExternalProviderClient applies for
|
||||
# request building). Match the host check here so the model
|
||||
# list and chat dispatch agree on what counts as "native".
|
||||
# Registry-level model-id filters are scoped to the canonical native
|
||||
# Gemini base. A custom Gemini OAI-compatible proxy (LiteLLM, deployment
|
||||
# gateway) returns IDs like `google/gemini-2.5-flash`,
|
||||
# `gemini/gemini-2.5-flash`, or team-prefixed deployment aliases; the
|
||||
# native allowlist regex would strip those and leave the picker empty,
|
||||
# even though the chat path routes them via the OAI-compatible
|
||||
# dispatcher (the same gate ExternalProviderClient applies for request
|
||||
# building). Match the host check here so the model list and chat
|
||||
# dispatch agree on what counts as "native".
|
||||
apply_registry_model_filters = True
|
||||
if payload.provider_type == "gemini":
|
||||
try:
|
||||
|
|
@ -344,11 +342,10 @@ async def list_provider_models(
|
|||
denylist = info.get("model_id_denylist")
|
||||
if denylist is not None:
|
||||
models = [m for m in models if not denylist.search(m.get("id", ""))]
|
||||
# Apply an optional cap after filtering so registry entries with a
|
||||
# large remote catalog (e.g. HF Inference Providers) can stay
|
||||
# picker-sized. No popularity sort happens server-side, so this is
|
||||
# "first N matches" — pair with default_models for any must-have
|
||||
# flagship ids.
|
||||
# Optional cap after filtering so registry entries with a large remote
|
||||
# catalog (e.g. HF Inference Providers) stay picker-sized. No
|
||||
# server-side popularity sort, so this is "first N matches" — pair with
|
||||
# default_models for any must-have flagship ids.
|
||||
limit = info.get("model_id_limit")
|
||||
if isinstance(limit, int) and limit > 0:
|
||||
models = models[:limit]
|
||||
|
|
|
|||
|
|
@ -16,13 +16,12 @@ import asyncio
|
|||
from datetime import datetime
|
||||
import uuid as _uuid
|
||||
|
||||
# Add backend directory to path
|
||||
# The backend code should be in the same directory structure
|
||||
# Add backend directory to path (same directory structure).
|
||||
backend_path = Path(__file__).parent.parent.parent
|
||||
if str(backend_path) not in sys.path:
|
||||
sys.path.insert(0, str(backend_path))
|
||||
|
||||
# Import backend functions
|
||||
# Import backend functions.
|
||||
try:
|
||||
from core.training import get_training_backend
|
||||
from core.training.resume import (
|
||||
|
|
@ -34,7 +33,7 @@ try:
|
|||
from utils.models.model_config import load_model_defaults
|
||||
from utils.paths import resolve_dataset_path
|
||||
except ImportError:
|
||||
# Fallback: try to import from parent directory
|
||||
# Fallback: import from the parent directory.
|
||||
parent_backend = backend_path.parent / "backend"
|
||||
if str(parent_backend) not in sys.path:
|
||||
sys.path.insert(0, str(parent_backend))
|
||||
|
|
@ -95,10 +94,9 @@ def _validate_local_dataset_paths(paths: list[str], label: str = "Local dataset"
|
|||
@router.get("/hardware")
|
||||
async def get_hardware_utilization(current_subject: str = Depends(get_current_subject)):
|
||||
"""
|
||||
Get a live snapshot of GPU hardware utilization.
|
||||
Live snapshot of GPU hardware utilization for the active backend.
|
||||
|
||||
Designed to be polled by the frontend during training.
|
||||
Returns live GPU memory usage information for the active backend.
|
||||
Polled by the frontend during training.
|
||||
"""
|
||||
from utils.hardware import get_gpu_utilization
|
||||
return get_gpu_utilization()
|
||||
|
|
@ -117,8 +115,8 @@ async def start_training(
|
|||
"""
|
||||
Start a training job.
|
||||
|
||||
This endpoint initiates training in the background and returns immediately.
|
||||
Use the /status endpoint to check training progress.
|
||||
Initiates training in the background and returns immediately. Use /status
|
||||
to check progress.
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Starting training job with model: {request.model_name}")
|
||||
|
|
@ -129,7 +127,7 @@ async def start_training(
|
|||
|
||||
backend = get_training_backend()
|
||||
|
||||
# Check if training is already active (before mutating any state)
|
||||
# Check if training is already active (before mutating state).
|
||||
if backend.is_training_active():
|
||||
existing_job_id: Optional[str] = getattr(backend, "current_job_id", "")
|
||||
return TrainingJobResponse(
|
||||
|
|
@ -142,11 +140,11 @@ async def start_training(
|
|||
error = "Training already active",
|
||||
)
|
||||
|
||||
# Generate job ID — passed into start_training() which sets it on the
|
||||
# backend only after confirming the old pump thread is dead.
|
||||
# Job ID — passed to start_training(), which sets it on the backend only
|
||||
# after confirming the old pump thread is dead.
|
||||
job_id = f"job_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{_uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Validate dataset paths if provided
|
||||
# Validate dataset paths if provided.
|
||||
if request.local_datasets:
|
||||
request.local_datasets = _validate_local_dataset_paths(
|
||||
request.local_datasets, "Local dataset"
|
||||
|
|
@ -178,7 +176,7 @@ async def start_training(
|
|||
)
|
||||
request.resume_from_checkpoint = resume_checkpoint
|
||||
|
||||
# Convert request to kwargs for backend
|
||||
# Convert request to backend kwargs.
|
||||
training_kwargs = {
|
||||
"model_name": request.model_name,
|
||||
"training_type": request.training_type,
|
||||
|
|
@ -242,8 +240,8 @@ async def start_training(
|
|||
}
|
||||
|
||||
# Training page has no trust_remote_code toggle — the value comes from
|
||||
# YAML model defaults applied when the user selects a model. As a safety
|
||||
# net, consult the YAML directly so models that need it always get it.
|
||||
# YAML model defaults on model select. As a safety net, consult the YAML
|
||||
# directly so models that need it always get it.
|
||||
if not training_kwargs["trust_remote_code"]:
|
||||
model_defaults = load_model_defaults(request.model_name)
|
||||
yaml_trust = model_defaults.get("training", {}).get("trust_remote_code", False)
|
||||
|
|
@ -252,7 +250,7 @@ async def start_training(
|
|||
training_kwargs["trust_remote_code"] = True
|
||||
|
||||
# Free GPU memory: shut down any running inference/export subprocesses
|
||||
# before training starts (they'd compete for VRAM otherwise)
|
||||
# before training (they'd compete for VRAM otherwise).
|
||||
try:
|
||||
from core.inference import get_inference_backend
|
||||
inf_backend = get_inference_backend()
|
||||
|
|
@ -279,7 +277,7 @@ async def start_training(
|
|||
except Exception as e:
|
||||
logger.warning("Could not shut down export subprocess: %s", e)
|
||||
|
||||
# start_training now spawns a subprocess (non-blocking)
|
||||
# start_training spawns a subprocess (non-blocking).
|
||||
success = backend.start_training(job_id = job_id, **training_kwargs)
|
||||
|
||||
if not success:
|
||||
|
|
@ -334,7 +332,6 @@ async def stop_training(
|
|||
status = "idle", message = "No training job is currently running"
|
||||
)
|
||||
|
||||
# Call backend stop method
|
||||
backend.stop_training(save = body.save)
|
||||
|
||||
return TrainingStopResponse(
|
||||
|
|
@ -354,16 +351,14 @@ async def stop_training(
|
|||
|
||||
@router.post("/reset")
|
||||
async def reset_training(current_subject: str = Depends(get_current_subject)):
|
||||
"""
|
||||
Reset training state so the user can return to configuration.
|
||||
"""
|
||||
"""Reset training state so the user can return to configuration."""
|
||||
try:
|
||||
backend = get_training_backend()
|
||||
is_active = backend.is_training_active()
|
||||
|
||||
if is_active:
|
||||
if backend._cancel_requested:
|
||||
# Cancel (save=False) was requested — force-terminate so we can reset immediately
|
||||
# Cancel (save=False) requested — force-terminate to reset immediately.
|
||||
logger.info("Force-terminating subprocess for immediate reset (cancel path)")
|
||||
backend.force_terminate()
|
||||
else:
|
||||
|
|
@ -460,7 +455,7 @@ async def get_training_status(current_subject: str = Depends(get_current_subject
|
|||
if output_dir:
|
||||
details["output_dir"] = output_dir
|
||||
|
||||
# Build metric history for chart recovery after SSE reconnection
|
||||
# Metric history for chart recovery after SSE reconnection.
|
||||
metric_history = None
|
||||
if backend.step_history:
|
||||
metric_history = {
|
||||
|
|
@ -540,16 +535,15 @@ async def stream_training_progress(
|
|||
request: Request, current_subject: str = Depends(get_current_subject)
|
||||
):
|
||||
"""
|
||||
Stream training progress updates using Server-Sent Events (SSE).
|
||||
Stream training progress via Server-Sent Events (SSE).
|
||||
|
||||
This endpoint provides real-time updates on training progress.
|
||||
Supports reconnection via the SSE spec:
|
||||
- Sends `id:` with each event so the browser tracks position.
|
||||
- Sends `retry:` to control reconnection interval.
|
||||
- Sends named `event:` types (progress, heartbeat, complete, error).
|
||||
- Reads `Last-Event-ID` header on reconnect to replay missed steps.
|
||||
Real-time progress with reconnection support per the SSE spec:
|
||||
- `id:` per event so the browser tracks position.
|
||||
- `retry:` to control reconnection interval.
|
||||
- Named `event:` types (progress, heartbeat, complete, error).
|
||||
- Reads `Last-Event-ID` on reconnect to replay missed steps.
|
||||
"""
|
||||
# Read Last-Event-ID header for reconnection resume
|
||||
# Read Last-Event-ID header for reconnection resume.
|
||||
last_event_id = request.headers.get("last-event-id")
|
||||
resume_from_step: Optional[int] = None
|
||||
if last_event_id is not None:
|
||||
|
|
@ -580,7 +574,7 @@ async def stream_training_progress(
|
|||
else:
|
||||
progress_percent = float(step) / float(total) * 100.0 if total > 0 else 0.0
|
||||
|
||||
# Get actual values from progress object if available
|
||||
# Pull values from the progress object if available.
|
||||
elapsed_seconds = getattr(progress, "elapsed_seconds", None) if progress else None
|
||||
eta_seconds = getattr(progress, "eta_seconds", None) if progress else None
|
||||
grad_norm = grad_norm_override
|
||||
|
|
@ -622,7 +616,7 @@ async def stream_training_progress(
|
|||
return "\n".join(lines)
|
||||
|
||||
# ── Retry directive ──────────────────────────────────────
|
||||
# Tell the browser to reconnect after 3 seconds if the connection drops
|
||||
# Reconnect after 3 seconds if the connection drops.
|
||||
yield "retry: 3000\n\n"
|
||||
|
||||
# ── Replay missed steps on reconnect ─────────────────────
|
||||
|
|
@ -707,7 +701,7 @@ async def stream_training_progress(
|
|||
# ── Live polling loop ────────────────────────────────────
|
||||
last_step = resume_from_step if resume_from_step is not None else -1
|
||||
no_update_count = 0
|
||||
max_no_updates = 1800 # Timeout after 30 minutes (large models need time for compilation)
|
||||
max_no_updates = 1800 # Timeout after 30 min (large models need compile time)
|
||||
|
||||
while backend.is_training_active():
|
||||
try:
|
||||
|
|
@ -721,7 +715,7 @@ async def stream_training_progress(
|
|||
)
|
||||
current_epoch = getattr(tp_inner, "epoch", None) if tp_inner else None
|
||||
|
||||
# Only send if step changed
|
||||
# Only send if the step changed.
|
||||
if current_step != last_step:
|
||||
progress_payload = build_progress(
|
||||
current_step,
|
||||
|
|
@ -740,7 +734,7 @@ async def stream_training_progress(
|
|||
no_update_count = 0
|
||||
else:
|
||||
no_update_count += 1
|
||||
# Send heartbeat every 10 seconds
|
||||
# Heartbeat every 10 seconds.
|
||||
if no_update_count % 10 == 0:
|
||||
heartbeat_payload = build_progress(
|
||||
current_step,
|
||||
|
|
@ -756,11 +750,11 @@ async def stream_training_progress(
|
|||
event_id = current_step,
|
||||
)
|
||||
else:
|
||||
# No steps yet, but training is active (model loading, etc.)
|
||||
# No steps yet, but training is active (model loading, etc.).
|
||||
no_update_count += 1
|
||||
if no_update_count % 5 == 0:
|
||||
# Pull total_steps and status from trainer so
|
||||
# the frontend can show "Tokenizing…" etc.
|
||||
# Pull total_steps + status from trainer so the frontend
|
||||
# can show "Tokenizing…" etc.
|
||||
tp_prep = getattr(
|
||||
getattr(backend, "trainer", None),
|
||||
"training_progress",
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""
|
||||
Run script for Unsloth UI Backend.
|
||||
Works independently and can be moved to any directory.
|
||||
"""Run script for Unsloth UI Backend.
|
||||
|
||||
Self-contained; can be moved to any directory.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
|
@ -11,10 +11,10 @@ import sys
|
|||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
# Suppress annoying C-level dependency warnings globally (e.g. SwigPyPacked)
|
||||
# Suppress C-level dependency warnings globally (e.g. SwigPyPacked).
|
||||
os.environ["PYTHONWARNINGS"] = "ignore"
|
||||
|
||||
# Add the backend directory to Python path early so local modules are importable
|
||||
# Add the backend dir to sys.path early so local modules import.
|
||||
backend_dir = Path(__file__).parent
|
||||
if str(backend_dir) not in sys.path:
|
||||
sys.path.insert(0, str(backend_dir))
|
||||
|
|
@ -27,8 +27,8 @@ except ValueError as exc:
|
|||
configured = os.environ.get("UNSLOTH_CPU_THREADS")
|
||||
raise SystemExit(f"Error: Invalid UNSLOTH_CPU_THREADS value {configured!r}: {exc}") from None
|
||||
|
||||
# Fix for Anaconda/conda-forge Python: seed platform._sys_version_cache before
|
||||
# any library imports that trigger attrs -> rich -> structlog -> platform crash.
|
||||
# Anaconda/conda-forge Python: seed platform._sys_version_cache before
|
||||
# imports that trigger attrs -> rich -> structlog -> platform crash.
|
||||
# See: https://github.com/python/cpython/issues/102396
|
||||
import _platform_compat # noqa: F401
|
||||
|
||||
|
|
@ -39,18 +39,17 @@ logger = get_logger(__name__)
|
|||
|
||||
|
||||
def _resolve_external_ip() -> str:
|
||||
"""
|
||||
Resolve the machine's external IP address.
|
||||
"""Resolve the machine's external IP address.
|
||||
|
||||
Tries (in order):
|
||||
1. GCE metadata server (instant, works on Google Cloud VMs)
|
||||
2. ifconfig.me (works anywhere with internet)
|
||||
Tries, in order:
|
||||
1. GCE metadata server (instant on Google Cloud VMs)
|
||||
2. ifconfig.me (anywhere with internet)
|
||||
3. LAN IP via UDP socket trick (fallback)
|
||||
"""
|
||||
import urllib.request
|
||||
import socket
|
||||
|
||||
# 1. Try GCE metadata server (responds in <10ms on GCE, times out fast elsewhere)
|
||||
# 1. GCE metadata server (<10ms on GCE, times out fast elsewhere).
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
"http://metadata.google.internal/computeMetadata/v1/instance/network-interfaces/0/access-configs/0/external-ip",
|
||||
|
|
@ -63,7 +62,7 @@ def _resolve_external_ip() -> str:
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
# 2. Try public IP service
|
||||
# 2. Public IP service.
|
||||
try:
|
||||
with urllib.request.urlopen("https://ifconfig.me", timeout = 3) as resp:
|
||||
ip = resp.read().decode().strip()
|
||||
|
|
@ -84,9 +83,10 @@ def _resolve_external_ip() -> str:
|
|||
|
||||
|
||||
def _install_uvicorn_startup_log_rewrite(bind_host: str, display_host: str) -> None:
|
||||
"""Rewrite Uvicorn's startup log line: swap wildcard bind for the
|
||||
externally-reachable address, replace the CTRL+C suffix with our Mac-aware
|
||||
stop hint, and rename the prefix to "Unsloth Studio running on"."""
|
||||
"""Rewrite Uvicorn's startup log line: swap the wildcard bind for the
|
||||
externally-reachable address, replace the CTRL+C suffix with our
|
||||
Mac-aware stop hint, and rename the prefix to "Unsloth Studio running
|
||||
on"."""
|
||||
import logging
|
||||
import re
|
||||
|
||||
|
|
@ -137,7 +137,7 @@ def _local_port_open(
|
|||
port: int,
|
||||
timeout: float = 1.0,
|
||||
) -> bool:
|
||||
"""Return True iff a TCP connection to (host, port) succeeds within timeout."""
|
||||
"""True iff a TCP connection to (host, port) succeeds within timeout."""
|
||||
import socket
|
||||
try:
|
||||
with socket.create_connection((host, port), timeout = timeout):
|
||||
|
|
@ -147,8 +147,8 @@ def _local_port_open(
|
|||
|
||||
|
||||
def _working_local_url(port: int) -> "str | None":
|
||||
"""Return a working loopback URL on this machine, or None if neither
|
||||
127.0.0.1 nor ::1 responds. Used as a fallback when external reachability fails."""
|
||||
"""A working loopback URL on this machine, or None if neither
|
||||
127.0.0.1 nor ::1 responds. Fallback when external reachability fails."""
|
||||
if _local_port_open("127.0.0.1", port):
|
||||
return f"http://127.0.0.1:{port}"
|
||||
if _local_port_open("::1", port):
|
||||
|
|
@ -157,13 +157,12 @@ def _working_local_url(port: int) -> "str | None":
|
|||
|
||||
|
||||
def _localhost_ipv6_mismatch_url(bind_host: str, port: int) -> "str | None":
|
||||
"""Return the IPv4 loopback URL when localhost will not reach 127.0.0.1.
|
||||
"""Return the IPv4 loopback URL when localhost won't reach 127.0.0.1.
|
||||
|
||||
Local Studio intentionally binds to 127.0.0.1. On hosts where localhost
|
||||
resolves to IPv6 only (::1), a browser pointed at http://localhost:<port>
|
||||
fails -- or worse, reaches a different process listening on ::1 -- even
|
||||
though http://127.0.0.1:<port> works. Return the IPv4 URL so the caller can
|
||||
tell the user which address to open.
|
||||
Local Studio binds to 127.0.0.1. Where localhost resolves to IPv6
|
||||
only (::1), http://localhost:<port> fails -- or worse, hits a
|
||||
different process on ::1 -- even though http://127.0.0.1:<port> works.
|
||||
Return the IPv4 URL so the caller can tell the user what to open.
|
||||
"""
|
||||
import socket
|
||||
|
||||
|
|
@ -172,7 +171,7 @@ def _localhost_ipv6_mismatch_url(bind_host: str, port: int) -> "str | None":
|
|||
|
||||
ipv4_url = f"http://127.0.0.1:{port}"
|
||||
|
||||
# Only warn once Studio is confirmed answering on the IPv4 loopback.
|
||||
# Only warn once Studio is confirmed answering on IPv4 loopback.
|
||||
if _working_local_url(port) != ipv4_url:
|
||||
return None
|
||||
|
||||
|
|
@ -194,11 +193,11 @@ def _localhost_ipv6_mismatch_url(bind_host: str, port: int) -> "str | None":
|
|||
if host == "::1":
|
||||
has_ipv6_loopback = True
|
||||
|
||||
# A successful connection to ::1 is NOT evidence that Studio is reachable
|
||||
# there: Studio binds 127.0.0.1 only, so anything answering on ::1 is a
|
||||
# different process -- which is exactly when the user must be steered to
|
||||
# 127.0.0.1. Dual-stack localhost is fine (browsers fall back to 127.0.0.1
|
||||
# when ::1 refuses), so only the IPv6-only case strands the user.
|
||||
# A connection to ::1 is NOT evidence Studio is reachable there:
|
||||
# Studio binds 127.0.0.1 only, so anything on ::1 is a different
|
||||
# process -- exactly when to steer the user to 127.0.0.1. Dual-stack
|
||||
# localhost is fine (browsers fall back to 127.0.0.1 when ::1
|
||||
# refuses), so only the IPv6-only case strands the user.
|
||||
if has_ipv6_loopback and not has_ipv4_loopback:
|
||||
return ipv4_url
|
||||
return None
|
||||
|
|
@ -231,11 +230,11 @@ def _print_localhost_ipv6_mismatch_warning(local_url: str, port: int) -> None:
|
|||
|
||||
|
||||
def _verify_global_reachability(display_host: str, port: int) -> None:
|
||||
"""Probe check-host.net to confirm display_host:port is reachable from the
|
||||
public internet. Synchronous so the caller can render output between the
|
||||
banner URL section and the trailing stop hint. Bounded at ~15s; failures
|
||||
are swallowed (the verifier failing is not Studio failing). Only meaningful
|
||||
when bound to a wildcard host."""
|
||||
"""Probe check-host.net to confirm display_host:port is reachable
|
||||
from the public internet. Synchronous so the caller can render output
|
||||
between the banner URL section and the trailing stop hint. Bounded at
|
||||
~15s; failures are swallowed (verifier failing != Studio failing).
|
||||
Only meaningful when bound to a wildcard host."""
|
||||
import ipaddress
|
||||
import json
|
||||
import time
|
||||
|
|
@ -256,7 +255,7 @@ def _verify_global_reachability(display_host: str, port: int) -> None:
|
|||
|
||||
url = f"http://{display_host}:{port}"
|
||||
|
||||
# Private / loopback / link-local addresses are not globally routable.
|
||||
# Private/loopback/link-local addresses aren't globally routable.
|
||||
try:
|
||||
addr = ipaddress.ip_address(display_host)
|
||||
if addr.is_loopback or addr.is_private or addr.is_link_local:
|
||||
|
|
@ -304,7 +303,7 @@ def _verify_global_reachability(display_host: str, port: int) -> None:
|
|||
continue
|
||||
if results and all(v is not None for v in results.values()):
|
||||
break
|
||||
# Two decisive nodes is enough; stop polling early.
|
||||
# Two decisive nodes is enough; stop early.
|
||||
decisive = [
|
||||
v
|
||||
for v in results.values()
|
||||
|
|
@ -373,7 +372,7 @@ def _verify_global_reachability(display_host: str, port: int) -> None:
|
|||
f"{dim} then open http://localhost:{port}/ in your browser.{reset}",
|
||||
flush = True,
|
||||
)
|
||||
# Only offer the local URL if loopback actually answers.
|
||||
# Only offer the local URL if loopback answers.
|
||||
local_url = _working_local_url(port)
|
||||
if local_url:
|
||||
print(
|
||||
|
|
@ -388,7 +387,7 @@ def _verify_global_reachability(display_host: str, port: int) -> None:
|
|||
flush = True,
|
||||
)
|
||||
except urllib.error.URLError:
|
||||
# Outbound HTTPS blocked; skip silently.
|
||||
# Outbound HTTPS blocked; skip.
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
|
@ -397,16 +396,16 @@ def _verify_global_reachability(display_host: str, port: int) -> None:
|
|||
def _emit_startup_output(host: str, port: int, display_host: str) -> None:
|
||||
"""Print the access banner plus any post-startup warnings.
|
||||
|
||||
Extracted from ``_run`` so the banner/warning wiring is unit-testable. The
|
||||
``localhost``-to-::1 mismatch warning and the wildcard reachability check
|
||||
are mutually exclusive (the mismatch helper returns None for any non
|
||||
127.0.0.1 bind, and wildcard binds are never 127.0.0.1), so the trailing
|
||||
stop hint is emitted exactly once.
|
||||
Extracted from ``_run`` so the banner/warning wiring is testable. The
|
||||
``localhost``-to-::1 mismatch warning and the wildcard reachability
|
||||
check are mutually exclusive (the mismatch helper returns None for any
|
||||
non-127.0.0.1 bind, and wildcard binds are never 127.0.0.1), so the
|
||||
trailing stop hint is emitted exactly once.
|
||||
"""
|
||||
wildcard_bind = host in ("0.0.0.0", "::")
|
||||
localhost_mismatch_url = _localhost_ipv6_mismatch_url(host, port)
|
||||
# For wildcard binds, run the reachability check between the URL section
|
||||
# and the stop hint so the stop hint stays last on screen.
|
||||
# For wildcard binds, run the reachability check between the URL
|
||||
# section and the stop hint so the stop hint stays last.
|
||||
print_studio_access_banner(
|
||||
port = port,
|
||||
bind_host = host,
|
||||
|
|
@ -422,12 +421,11 @@ def _emit_startup_output(host: str, port: int, display_host: str) -> None:
|
|||
|
||||
|
||||
def _get_pid_on_port(port: int) -> "tuple[int, str] | None":
|
||||
"""Return (pid, process_name) of the process listening on *port*, or None.
|
||||
"""Return (pid, process_name) listening on *port*, or None.
|
||||
|
||||
Uses psutil when available. Falls back gracefully to None so callers
|
||||
can still report the port conflict without process details.
|
||||
|
||||
Works on Windows, macOS, and Linux wherever psutil is installed.
|
||||
Uses psutil when available, falling back to None so callers can still
|
||||
report the conflict without process details. Works on Windows, macOS,
|
||||
and Linux wherever psutil is installed.
|
||||
"""
|
||||
try:
|
||||
import psutil
|
||||
|
|
@ -444,7 +442,7 @@ def _get_pid_on_port(port: int) -> "tuple[int, str] | None":
|
|||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
return (conn.pid, "<unknown>")
|
||||
except (psutil.AccessDenied, OSError) as e:
|
||||
# psutil.net_connections() needs elevated privileges on some platforms
|
||||
# net_connections() needs elevated privileges on some platforms.
|
||||
logger.debug("Failed to scan network connections for port %s: %s", port, e)
|
||||
return None
|
||||
|
||||
|
|
@ -452,19 +450,17 @@ def _get_pid_on_port(port: int) -> "tuple[int, str] | None":
|
|||
def _is_port_free(host: str, port: int) -> bool:
|
||||
"""Check if a port is available for binding.
|
||||
|
||||
When *host* is ``0.0.0.0`` (wildcard), we also check whether anything
|
||||
is already listening on ``127.0.0.1`` (and ``::1`` when IPv6 is
|
||||
available). An SSH tunnel or similar process may hold the loopback
|
||||
address while our wildcard bind still succeeds, making Unsloth Studio
|
||||
unreachable via ``localhost``.
|
||||
When *host* is ``0.0.0.0`` (wildcard), also check whether anything is
|
||||
already listening on ``127.0.0.1`` (and ``::1`` when IPv6 exists). An
|
||||
SSH tunnel may hold the loopback address while our wildcard bind
|
||||
succeeds, making Studio unreachable via ``localhost``.
|
||||
|
||||
Works on Windows, macOS, and Linux.
|
||||
"""
|
||||
import socket
|
||||
|
||||
# 1. Can we bind to the requested address?
|
||||
# Use getaddrinfo so both IPv4 ("0.0.0.0") and IPv6 ("::") hosts
|
||||
# resolve to the correct address family automatically.
|
||||
# 1. Can we bind to the requested address? getaddrinfo resolves both
|
||||
# IPv4 ("0.0.0.0") and IPv6 ("::") to the right address family.
|
||||
try:
|
||||
addr_info = socket.getaddrinfo(host, port, socket.AF_UNSPEC, socket.SOCK_STREAM)
|
||||
family, socktype, proto, _, sockaddr = addr_info[0]
|
||||
|
|
@ -474,9 +470,9 @@ def _is_port_free(host: str, port: int) -> bool:
|
|||
except OSError:
|
||||
return False
|
||||
|
||||
# 2. When binding to all interfaces, verify that localhost is not
|
||||
# already claimed by another process (e.g. an SSH -L tunnel).
|
||||
# We attempt a TCP connect -- if it succeeds something is listening.
|
||||
# 2. When binding to all interfaces, verify localhost isn't already
|
||||
# claimed by another process (e.g. an SSH -L tunnel). A successful
|
||||
# TCP connect means something is listening.
|
||||
if host in ("0.0.0.0", "::"):
|
||||
for loopback, family in [
|
||||
("127.0.0.1", socket.AF_INET),
|
||||
|
|
@ -486,10 +482,10 @@ def _is_port_free(host: str, port: int) -> bool:
|
|||
with socket.socket(family, socket.SOCK_STREAM) as s:
|
||||
s.settimeout(1)
|
||||
if s.connect_ex((loopback, port)) == 0:
|
||||
# Connection succeeded -- port is taken on loopback
|
||||
# Port is taken on loopback.
|
||||
return False
|
||||
except OSError:
|
||||
# IPv6 disabled or other OS-level restriction -- skip
|
||||
# IPv6 disabled or other OS-level restriction -- skip.
|
||||
continue
|
||||
|
||||
return True
|
||||
|
|
@ -500,7 +496,7 @@ def _find_free_port(
|
|||
start: int,
|
||||
max_attempts: int = 20,
|
||||
) -> int:
|
||||
"""Find a free port starting from `start`, trying up to max_attempts ports."""
|
||||
"""Find a free port from `start`, trying up to max_attempts ports."""
|
||||
for offset in range(max_attempts):
|
||||
candidate = start + offset
|
||||
if _is_port_free(host, candidate):
|
||||
|
|
@ -514,7 +510,7 @@ _PID_FILE = _studio_root() / "studio.pid"
|
|||
|
||||
# Direct backend launches bypass the CLI's env re-export; do it here for
|
||||
# real custom roots so unsloth-zoo's import-time LLAMA_CPP_DEFAULT_DIR
|
||||
# picks up the custom build. Skip for legacy-default to avoid flipping
|
||||
# picks up the custom build. Skip legacy-default to avoid flipping
|
||||
# default-mode installs into env-override.
|
||||
try:
|
||||
_LEGACY_STUDIO_ROOT = (Path.home() / ".unsloth" / "studio").resolve()
|
||||
|
|
@ -552,20 +548,20 @@ def _remove_pid_file():
|
|||
|
||||
|
||||
def _graceful_shutdown(server = None):
|
||||
"""Explicitly shut down all subprocess backends and the uvicorn server.
|
||||
"""Shut down all subprocess backends and the uvicorn server.
|
||||
|
||||
Called from signal handlers to ensure child processes are cleaned up
|
||||
before the parent exits. This is critical on Windows where atexit
|
||||
handlers are unreliable after Ctrl+C.
|
||||
Called from signal handlers to clean up child processes before the
|
||||
parent exits. Critical on Windows where atexit handlers are
|
||||
unreliable after Ctrl+C.
|
||||
"""
|
||||
_remove_pid_file()
|
||||
logger.info("Graceful shutdown initiated — cleaning up subprocesses...")
|
||||
|
||||
# 1. Shut down uvicorn server (releases the listening socket)
|
||||
# 1. Shut down uvicorn (releases the listening socket).
|
||||
if server is not None:
|
||||
server.should_exit = True
|
||||
|
||||
# 2. Clean up inference subprocess (if instantiated)
|
||||
# 2. Clean up inference subprocess (if instantiated).
|
||||
try:
|
||||
from core.inference.orchestrator import _inference_backend
|
||||
if _inference_backend is not None:
|
||||
|
|
@ -573,7 +569,7 @@ def _graceful_shutdown(server = None):
|
|||
except Exception as e:
|
||||
logger.warning("Error shutting down inference subprocess: %s", e)
|
||||
|
||||
# 3. Clean up export subprocess (if instantiated)
|
||||
# 3. Clean up export subprocess (if instantiated).
|
||||
try:
|
||||
from core.export.orchestrator import _export_backend
|
||||
if _export_backend is not None:
|
||||
|
|
@ -581,7 +577,7 @@ def _graceful_shutdown(server = None):
|
|||
except Exception as e:
|
||||
logger.warning("Error shutting down export subprocess: %s", e)
|
||||
|
||||
# 4. Clean up training subprocess (if active)
|
||||
# 4. Clean up training subprocess (if active).
|
||||
try:
|
||||
from core.training.training import _training_backend
|
||||
if _training_backend is not None:
|
||||
|
|
@ -589,7 +585,7 @@ def _graceful_shutdown(server = None):
|
|||
except Exception as e:
|
||||
logger.warning("Error shutting down training subprocess: %s", e)
|
||||
|
||||
# 5. Kill llama-server subprocess (if loaded)
|
||||
# 5. Kill llama-server subprocess (if loaded).
|
||||
try:
|
||||
from routes.inference import _llama_cpp_backend
|
||||
if _llama_cpp_backend is not None:
|
||||
|
|
@ -601,10 +597,10 @@ def _graceful_shutdown(server = None):
|
|||
|
||||
|
||||
# The uvicorn server instance -- set by run_server(), used by callers
|
||||
# that need to tell the server to exit (e.g. signal handlers).
|
||||
# that tell the server to exit (e.g. signal handlers).
|
||||
_server = None
|
||||
|
||||
# Shutdown event -- used to wake the main loop on signal
|
||||
# Shutdown event -- wakes the main loop on signal.
|
||||
_shutdown_event = None
|
||||
|
||||
|
||||
|
|
@ -615,8 +611,8 @@ def _iter_frontend_fallback_candidates() -> "list[Path]":
|
|||
"""Yield `studio/frontend/dist` paths to try when the default is missing.
|
||||
|
||||
Covers PATH-shadowed binaries whose __file__ resolves into a
|
||||
site-packages tree that never received a vite build (e.g. plain
|
||||
`pip install unsloth` from PyPI).
|
||||
site-packages tree with no vite build (e.g. plain `pip install
|
||||
unsloth` from PyPI).
|
||||
"""
|
||||
import ast
|
||||
import re
|
||||
|
|
@ -642,9 +638,9 @@ def _iter_frontend_fallback_candidates() -> "list[Path]":
|
|||
src = finder.read_text(encoding = "utf-8")
|
||||
except OSError:
|
||||
continue
|
||||
# Tolerate single- or multi-line dict literals; [^}]* still
|
||||
# rejects nested dicts, which the setuptools template never
|
||||
# emits for editable installs.
|
||||
# Tolerate single- or multi-line dict literals; [^}]*
|
||||
# still rejects nested dicts, which the setuptools
|
||||
# template never emits for editable installs.
|
||||
m = re.search(r"^MAPPING\s*(?::[^=]*)?=\s*(\{[^}]*\})", src, re.M | re.S)
|
||||
if not m:
|
||||
continue
|
||||
|
|
@ -652,8 +648,8 @@ def _iter_frontend_fallback_candidates() -> "list[Path]":
|
|||
mapping = ast.literal_eval(m.group(1))
|
||||
except (SyntaxError, ValueError):
|
||||
continue
|
||||
# Defensive: literal_eval can return a set / list / None if the
|
||||
# matched literal is not a dict (regex captures `{...}`).
|
||||
# Defensive: literal_eval can return a set/list/None if
|
||||
# the matched `{...}` literal isn't a dict.
|
||||
if not isinstance(mapping, dict):
|
||||
continue
|
||||
studio_pkg = mapping.get("studio")
|
||||
|
|
@ -663,10 +659,10 @@ def _iter_frontend_fallback_candidates() -> "list[Path]":
|
|||
|
||||
|
||||
def _resolve_frontend_path(frontend_path: Path) -> tuple[Optional[Path], list[Path]]:
|
||||
"""Pick a frontend dir that actually contains `index.html`.
|
||||
"""Pick a frontend dir that contains `index.html`.
|
||||
|
||||
Returns (chosen, attempted). `chosen` is None if nothing servable was
|
||||
found; `attempted` is the full ordered list for diagnostics.
|
||||
found; `attempted` is the ordered list for diagnostics.
|
||||
"""
|
||||
attempted: list[Path] = []
|
||||
seen: set[Path] = set()
|
||||
|
|
@ -706,25 +702,25 @@ def run_server(
|
|||
port: Port to bind to (auto-increments if in use)
|
||||
frontend_path: Path to frontend build directory (optional)
|
||||
silent: Suppress startup messages
|
||||
api_only: Run API server only, no frontend serving (for Tauri desktop app)
|
||||
llama_parallel_slots: Number of parallel slots for llama-server
|
||||
api_only: API server only, no frontend (for Tauri desktop app)
|
||||
llama_parallel_slots: parallel slots for llama-server
|
||||
|
||||
Note:
|
||||
Signal handlers are NOT registered here so that embedders
|
||||
(e.g. Colab notebooks) keep their own interrupt semantics.
|
||||
Standalone callers should register handlers after calling this.
|
||||
Signal handlers are NOT registered here so embedders (e.g. Colab
|
||||
notebooks) keep their own interrupt semantics. Standalone callers
|
||||
should register handlers after calling this.
|
||||
"""
|
||||
global _server, _shutdown_event
|
||||
|
||||
# On Windows the default console encoding (cp1252) cannot encode emoji.
|
||||
# Reconfigure stdout to UTF-8 so startup messages do not crash the server.
|
||||
# Windows console encoding (cp1252) can't encode emoji. Reconfigure
|
||||
# stdout to UTF-8 so startup messages don't crash the server.
|
||||
if sys.platform == "win32" and hasattr(sys.stdout, "reconfigure"):
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding = "utf-8", errors = "replace")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Set env var BEFORE importing main so CORS middleware picks it up
|
||||
# Set env var BEFORE importing main so CORS middleware picks it up.
|
||||
if api_only:
|
||||
os.environ["UNSLOTH_API_ONLY"] = "1"
|
||||
|
||||
|
|
@ -739,10 +735,10 @@ def run_server(
|
|||
from main import app, setup_frontend, _IS_COLAB
|
||||
from utils.paths import ensure_studio_directories
|
||||
|
||||
# Create all standard directories on startup
|
||||
# Create all standard directories on startup.
|
||||
ensure_studio_directories()
|
||||
|
||||
# Auto-find free port if requested port is in use
|
||||
# Auto-find a free port if the requested one is in use.
|
||||
if not _is_port_free(host, port):
|
||||
original_port = port
|
||||
blocker = _get_pid_on_port(port)
|
||||
|
|
@ -760,14 +756,14 @@ def run_server(
|
|||
print("=" * 50)
|
||||
print("")
|
||||
|
||||
# Setup frontend if path provided (skip in api-only mode).
|
||||
# Falls back through alternate locations if the default lacks a built
|
||||
# dist; errors out loudly rather than silently serving 404 on `/`.
|
||||
# Setup frontend if path provided (skip in api-only mode). Falls back
|
||||
# through alternate locations if the default lacks a built dist;
|
||||
# errors loudly rather than serving 404 on `/`.
|
||||
if frontend_path and not api_only:
|
||||
chosen, attempted = _resolve_frontend_path(Path(frontend_path))
|
||||
if chosen is not None and setup_frontend(app, chosen):
|
||||
if not silent:
|
||||
# Resolve so logs always show an absolute path for support.
|
||||
# Resolve so logs show an absolute path for support.
|
||||
try:
|
||||
display = chosen.resolve()
|
||||
except OSError:
|
||||
|
|
@ -779,8 +775,8 @@ def run_server(
|
|||
or os.environ.get("STUDIO_HOME")
|
||||
or str(Path.home() / ".unsloth" / "studio")
|
||||
)
|
||||
# Windows ships the user-facing shim at $STUDIO_HOME/bin/unsloth.exe
|
||||
# (a hardlink to the venv exe); Linux/macOS use the venv binary
|
||||
# Windows ships the shim at $STUDIO_HOME/bin/unsloth.exe (a
|
||||
# hardlink to the venv exe); Linux/macOS use the venv binary
|
||||
# at $STUDIO_HOME/unsloth_studio/bin/unsloth.
|
||||
home = Path(home_str).expanduser()
|
||||
if sys.platform == "win32":
|
||||
|
|
@ -803,7 +799,7 @@ def run_server(
|
|||
" - reinstall: curl -fsSL https://unsloth.ai/install.sh | sh"
|
||||
)
|
||||
|
||||
# Resolve once; shared by the log rewrite and the banner.
|
||||
# Resolve once; shared by the log rewrite and banner.
|
||||
display_host = _resolve_external_ip() if host == "0.0.0.0" else host
|
||||
_install_uvicorn_startup_log_rewrite(host, display_host)
|
||||
|
||||
|
|
@ -825,11 +821,11 @@ def run_server(
|
|||
access_log = False,
|
||||
server_header = False,
|
||||
)
|
||||
# Only in Colab: trust X-Forwarded-* from Colab's reverse proxy so the app
|
||||
# sees the real https origin. forwarded_allow_ips="*" is fine inside Colab's
|
||||
# single-user sandbox, but would be an unwanted security relaxation for a
|
||||
# normal local/standalone Studio, so leave uvicorn's safe defaults
|
||||
# (forwarded headers trusted from loopback only) in place there.
|
||||
# Colab only: trust X-Forwarded-* from Colab's reverse proxy so the
|
||||
# app sees the real https origin. forwarded_allow_ips="*" is fine in
|
||||
# Colab's single-user sandbox but an unwanted relaxation for a normal
|
||||
# local/standalone Studio, so leave uvicorn's safe defaults
|
||||
# (forwarded headers trusted from loopback only) elsewhere.
|
||||
if _IS_COLAB:
|
||||
config_kwargs["proxy_headers"] = True
|
||||
config_kwargs["forwarded_allow_ips"] = "*"
|
||||
|
|
@ -837,17 +833,16 @@ def run_server(
|
|||
_server = _ReadyServer(config)
|
||||
_shutdown_event = Event()
|
||||
|
||||
# Expose the actual bound port so request-handling code can build
|
||||
# loopback URLs that point at the real backend, not whatever port a
|
||||
# reverse proxy or tunnel exposed in the request URL. Only publish
|
||||
# an explicit value when we know the concrete port; for ephemeral
|
||||
# binds (port==0) leave it unset and let request handlers fall back
|
||||
# to the ASGI request scope or request.base_url.
|
||||
# Expose the actual bound port so request handlers build loopback
|
||||
# URLs pointing at the real backend, not whatever port a proxy/tunnel
|
||||
# exposed in the request URL. Only publish a concrete port; for
|
||||
# ephemeral binds (port==0) leave it unset so handlers fall back to
|
||||
# the ASGI request scope or request.base_url.
|
||||
app.state.server_port = port if port and port > 0 else None
|
||||
app.state.llama_parallel_slots = llama_parallel_slots
|
||||
|
||||
# Expose a shutdown callable via app.state before the server can accept
|
||||
# requests so /api/shutdown is available as soon as readiness is published.
|
||||
# Expose a shutdown callable via app.state before the server accepts
|
||||
# requests so /api/shutdown is ready as soon as readiness publishes.
|
||||
def _trigger_shutdown():
|
||||
_graceful_shutdown(_server)
|
||||
if _shutdown_event is not None:
|
||||
|
|
@ -855,11 +850,10 @@ def run_server(
|
|||
|
||||
app.state.trigger_shutdown = _trigger_shutdown
|
||||
|
||||
# Run server in a daemon thread.
|
||||
# Use an explicit new_event_loop() + run_until_complete() instead of
|
||||
# asyncio.run() to avoid nest_asyncio's global patches to asyncio.run
|
||||
# interfering when called from a thread while Colab/IPython already has
|
||||
# a running loop on the main thread.
|
||||
# Run server in a daemon thread. Use explicit new_event_loop() +
|
||||
# run_until_complete() rather than asyncio.run() so nest_asyncio's
|
||||
# global patches to asyncio.run don't interfere when called from a
|
||||
# thread while Colab/IPython already runs a loop on the main thread.
|
||||
def _run():
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
|
@ -876,9 +870,9 @@ def run_server(
|
|||
thread = Thread(target = _run, daemon = True)
|
||||
thread.start()
|
||||
|
||||
# Wait until uvicorn has completed lifespan startup and bound sockets, or
|
||||
# until the server exits/fails before startup. This intentionally has no
|
||||
# correctness deadline: a slow but live startup should remain in progress.
|
||||
# Wait until uvicorn finishes lifespan startup and binds sockets, or
|
||||
# until the server exits/fails before startup. No correctness
|
||||
# deadline: a slow but live startup should remain in progress.
|
||||
try:
|
||||
while not ready_event.is_set():
|
||||
if startup_failed.is_set() or not thread.is_alive():
|
||||
|
|
@ -898,8 +892,8 @@ def run_server(
|
|||
|
||||
atexit.register(_remove_pid_file)
|
||||
|
||||
# Output port for Tauri to parse when in api-only mode. Emit only after
|
||||
# uvicorn sockets are bound and FastAPI lifespan/startup has completed.
|
||||
# Output port for Tauri to parse in api-only mode. Emit only after
|
||||
# uvicorn sockets are bound and FastAPI startup completed.
|
||||
if api_only:
|
||||
print(f"TAURI_PORT={port}", flush = True)
|
||||
|
||||
|
|
@ -909,13 +903,13 @@ def run_server(
|
|||
return app
|
||||
|
||||
|
||||
# For direct execution (also invoked by CLI via os.execvp / subprocess)
|
||||
# For direct execution (also invoked by CLI via os.execvp / subprocess).
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
import signal
|
||||
import traceback
|
||||
|
||||
# Ensure stderr can handle Unicode on Windows (tracebacks with non-ASCII paths)
|
||||
# Ensure stderr handles Unicode on Windows (non-ASCII path tracebacks).
|
||||
if sys.platform == "win32" and hasattr(sys.stderr, "reconfigure"):
|
||||
try:
|
||||
sys.stderr.reconfigure(encoding = "utf-8", errors = "replace")
|
||||
|
|
@ -942,7 +936,7 @@ if __name__ == "__main__":
|
|||
help = "API server only, no frontend (for Tauri)",
|
||||
)
|
||||
# Mirror unsloth_cli/commands/studio.py's _PARALLEL_*. Default 1
|
||||
# applies only to direct backend launches; `unsloth studio run`
|
||||
# applies to direct backend launches only; `unsloth studio run`
|
||||
# always passes its own value (4) explicitly.
|
||||
_PARALLEL_MIN = 1
|
||||
_PARALLEL_MAX = 64
|
||||
|
|
@ -985,7 +979,7 @@ if __name__ == "__main__":
|
|||
sys.stderr.flush()
|
||||
sys.exit(1)
|
||||
|
||||
# Signal handler -- ensures subprocess cleanup on Ctrl+C
|
||||
# Signal handler -- ensures subprocess cleanup on Ctrl+C.
|
||||
def _signal_handler(signum, frame):
|
||||
_graceful_shutdown(_server)
|
||||
_shutdown_event.set()
|
||||
|
|
@ -993,13 +987,13 @@ if __name__ == "__main__":
|
|||
signal.signal(signal.SIGINT, _signal_handler)
|
||||
signal.signal(signal.SIGTERM, _signal_handler)
|
||||
|
||||
# On Windows, some terminals send SIGBREAK for Ctrl+C / Ctrl+Break
|
||||
# On Windows, some terminals send SIGBREAK for Ctrl+C / Ctrl+Break.
|
||||
if hasattr(signal, "SIGBREAK"):
|
||||
signal.signal(signal.SIGBREAK, _signal_handler)
|
||||
|
||||
# Keep running until shutdown signal.
|
||||
# NOTE: Event.wait() without a timeout blocks at the C level on Linux,
|
||||
# which prevents Python from delivering SIGINT (Ctrl+C). Using a
|
||||
# short timeout in a loop lets the interpreter process pending signals.
|
||||
# preventing Python from delivering SIGINT (Ctrl+C). A short timeout
|
||||
# in a loop lets the interpreter process pending signals.
|
||||
while not _shutdown_event.is_set():
|
||||
_shutdown_event.wait(timeout = 1)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
"""Terminal banner for Studio startup.
|
||||
|
||||
Stdlib only — safe to import without the rest of the backend (no structlog/uvicorn).
|
||||
Stdlib only — safe to import without the rest of the backend.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -34,7 +34,7 @@ def print_port_in_use_notice(original_port: int, new_port: int) -> None:
|
|||
|
||||
|
||||
def print_studio_stop_hint() -> None:
|
||||
"""Print the trailing stop hint + closing divider. Separate from the main
|
||||
"""Print the trailing stop hint + closing divider. Separate from the
|
||||
banner so callers can interleave content (e.g. a reachability check)."""
|
||||
use_color = stdout_supports_color()
|
||||
dim = "\033[38;5;245m"
|
||||
|
|
@ -67,7 +67,7 @@ def print_studio_access_banner(
|
|||
display_host: str,
|
||||
include_stop_hint: bool = True,
|
||||
) -> None:
|
||||
"""Pretty-print URLs after the server is listening. Set
|
||||
"""Pretty-print URLs once the server is listening. Set
|
||||
``include_stop_hint=False`` to omit the trailing stop block; pair with
|
||||
:func:`print_studio_stop_hint` after inserting your own content."""
|
||||
use_color = stdout_supports_color()
|
||||
|
|
@ -96,8 +96,8 @@ def print_studio_access_banner(
|
|||
listen_all = bind_host in ("0.0.0.0", "::")
|
||||
loopback_bind = bind_host in ("127.0.0.1", "localhost", "::1")
|
||||
|
||||
# Use loopback URL only when the server is reachable on loopback;
|
||||
# otherwise show the actual bound address.
|
||||
# Use the loopback URL only when reachable on loopback; otherwise show
|
||||
# the actual bound address.
|
||||
primary_url = loopback_url if listen_all or loopback_bind else external_url
|
||||
tip_url = alt_local if listen_all or loopback_bind else external_url
|
||||
api_base = primary_url
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ def _ensure_schema(conn: sqlite3.Connection) -> None:
|
|||
)
|
||||
"""
|
||||
)
|
||||
# use_oauth was added after the first release; backfill for pre-existing DBs.
|
||||
# use_oauth added after the first release; backfill for pre-existing DBs.
|
||||
cols = {r["name"] for r in conn.execute("PRAGMA table_info(mcp_servers)").fetchall()}
|
||||
if "use_oauth" not in cols:
|
||||
conn.execute("ALTER TABLE mcp_servers ADD COLUMN use_oauth INTEGER NOT NULL DEFAULT 0")
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@
|
|||
"""
|
||||
SQLite storage for external LLM provider configurations.
|
||||
|
||||
Follows the same pattern as studio_db.py — module-level functions,
|
||||
raw sqlite3, WAL mode, per-function connections.
|
||||
Same pattern as studio_db.py: module-level functions, raw sqlite3, WAL
|
||||
mode, per-function connections.
|
||||
|
||||
NOTE: API keys are NOT stored here. They live only in the browser
|
||||
(localStorage) and are sent encrypted per-request.
|
||||
|
|
@ -26,7 +26,7 @@ _schema_ready = False
|
|||
|
||||
|
||||
def _ensure_schema(conn: sqlite3.Connection) -> None:
|
||||
"""Create the llm_providers table if it doesn't exist. Called once per process."""
|
||||
"""Create the llm_providers table if absent. Called once per process."""
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute(
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@
|
|||
"""
|
||||
SQLite storage for training run history and metrics.
|
||||
|
||||
Follows the same pattern as auth/storage.py — module-level functions,
|
||||
raw sqlite3, per-function connections. Enhancements over auth:
|
||||
Same pattern as auth/storage.py — module-level functions, raw sqlite3,
|
||||
per-function connections. Enhancements over auth:
|
||||
- WAL mode for concurrent read/write access
|
||||
- PRAGMA foreign_keys = ON for CASCADE deletes
|
||||
"""
|
||||
|
|
@ -34,8 +34,8 @@ def _denied_path_prefixes() -> list[str]:
|
|||
if system == "Linux":
|
||||
return ["/proc", "/sys", "/dev", "/etc", "/boot", "/run"]
|
||||
if system == "Darwin":
|
||||
# realpath() resolves /etc -> /private/etc, /tmp -> /private/tmp on macOS,
|
||||
# so include the /private variants to avoid bypasses.
|
||||
# realpath() resolves /etc -> /private/etc, /tmp -> /private/tmp on
|
||||
# macOS, so include the /private variants to avoid bypasses.
|
||||
return [
|
||||
"/System",
|
||||
"/Library",
|
||||
|
|
@ -174,9 +174,9 @@ def _ensure_schema(conn: sqlite3.Connection) -> None:
|
|||
"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_metrics_run_id ON training_metrics(run_id)")
|
||||
# Use COLLATE NOCASE on Windows so C:\Models and c:\models dedup via the
|
||||
# UNIQUE constraint. On Linux/macOS (case-sensitive FS) keep the default
|
||||
# BINARY collation so /Models and /models remain distinct.
|
||||
# COLLATE NOCASE on Windows so C:\Models and c:\models dedup via the
|
||||
# UNIQUE constraint. On Linux/macOS (case-sensitive FS) keep the default
|
||||
# BINARY collation so /Models and /models stay distinct.
|
||||
collation = "COLLATE NOCASE" if platform.system() == "Windows" else ""
|
||||
conn.execute(
|
||||
f"""
|
||||
|
|
@ -287,15 +287,14 @@ def _ensure_schema(conn: sqlite3.Connection) -> None:
|
|||
)
|
||||
"""
|
||||
)
|
||||
# Server-side import ledger so a studio.db wipe correctly re-triggers
|
||||
# the legacy Dexie import. The previous boolean localStorage sentinel
|
||||
# (`unsloth_chat_legacy_imported_to_studio_db`) is non-recoverable:
|
||||
# if studio.db is recreated while the browser keeps the flag, legacy
|
||||
# Dexie threads are silently hidden from the sidebar. The ledger
|
||||
# lives inside studio.db so it disappears together with the data it
|
||||
# is supposed to track, which is the recovery the boolean lacked.
|
||||
# Keyed by legacy thread id; per-thread is sufficient because Dexie
|
||||
# is read-only after this PR (a thread's message set does not grow).
|
||||
# Server-side import ledger so a studio.db wipe re-triggers the legacy
|
||||
# Dexie import. The old boolean localStorage sentinel
|
||||
# (`unsloth_chat_legacy_imported_to_studio_db`) was non-recoverable: if
|
||||
# studio.db is recreated while the browser keeps the flag, legacy Dexie
|
||||
# threads are silently hidden from the sidebar. The ledger lives inside
|
||||
# studio.db so it disappears with the data it tracks -- the recovery the
|
||||
# boolean lacked. Keyed by legacy thread id; per-thread suffices because
|
||||
# Dexie is read-only after this PR (a thread's message set doesn't grow).
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS chat_legacy_imports (
|
||||
|
|
@ -313,7 +312,7 @@ def get_connection() -> sqlite3.Connection:
|
|||
ensure_dir(db_path.parent)
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
# foreign_keys is session-scoped, must be set per connection
|
||||
# foreign_keys is session-scoped; set per connection
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
if not _schema_ready:
|
||||
with _schema_lock:
|
||||
|
|
@ -701,9 +700,9 @@ def add_scan_folder(path: str) -> dict:
|
|||
if not os.access(normalized, os.R_OK | os.X_OK):
|
||||
raise ValueError("Path is not readable")
|
||||
|
||||
# On Windows, use normcase for denylist comparison but store the
|
||||
# original-cased path so downstream consumers see the native
|
||||
# drive-letter casing the user expects (e.g. C:\Models, not c:\models).
|
||||
# On Windows, normcase for the denylist comparison but store the
|
||||
# original-cased path so downstream consumers see the native drive-letter
|
||||
# casing the user expects (e.g. C:\Models, not c:\models).
|
||||
is_win = platform.system() == "Windows"
|
||||
check = os.path.normcase(normalized) if is_win else normalized
|
||||
for prefix in _denied_path_prefixes():
|
||||
|
|
@ -713,8 +712,8 @@ def add_scan_folder(path: str) -> dict:
|
|||
conn = get_connection()
|
||||
try:
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
# On Windows, use case-insensitive lookup so C:\Models and c:\models
|
||||
# dedup correctly while preserving the originally-stored casing.
|
||||
# On Windows, case-insensitive lookup so C:\Models and c:\models dedup
|
||||
# while preserving the originally-stored casing.
|
||||
if is_win:
|
||||
existing = conn.execute(
|
||||
"SELECT id, path, created_at FROM scan_folders WHERE path = ? COLLATE NOCASE",
|
||||
|
|
@ -735,8 +734,8 @@ def add_scan_folder(path: str) -> dict:
|
|||
conn.commit()
|
||||
except sqlite3.IntegrityError:
|
||||
pass # duplicate -- fall through to SELECT
|
||||
# Use the same collation as the pre-check so we find the row even
|
||||
# when a concurrent writer stored it with different casing (Windows).
|
||||
# Same collation as the pre-check so we find the row even when a
|
||||
# concurrent writer stored it with different casing (Windows).
|
||||
fallback_sql = (
|
||||
"SELECT id, path, created_at FROM scan_folders WHERE path = ? COLLATE NOCASE"
|
||||
if is_win
|
||||
|
|
@ -1408,8 +1407,8 @@ def _deep_merge_settings(current: dict[str, Any], updates: dict[str, Any]) -> di
|
|||
|
||||
|
||||
def upsert_chat_settings_merge(updates: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Atomic read-merge-write under BEGIN IMMEDIATE so two concurrent writers
|
||||
cannot drop one another's updates."""
|
||||
"""Atomic read-merge-write under BEGIN IMMEDIATE so concurrent writers
|
||||
cannot drop each other's updates."""
|
||||
if not updates:
|
||||
return list_chat_settings()
|
||||
conn = get_connection()
|
||||
|
|
@ -1457,8 +1456,8 @@ def upsert_chat_settings_merge(updates: dict[str, Any]) -> dict[str, Any]:
|
|||
def list_chat_legacy_imports() -> list[str]:
|
||||
"""Return the legacy_thread_id of every thread already imported.
|
||||
|
||||
Cheap: scans a single small PK-only table. The frontend stuffs the
|
||||
result into a Set before walking Dexie, so the diff is O(|Dexie|).
|
||||
Cheap: scans one small PK-only table. The frontend puts the result in a
|
||||
Set before walking Dexie, so the diff is O(|Dexie|).
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
|
|
@ -1472,13 +1471,13 @@ def upsert_chat_legacy_imports(legacy_thread_ids: list[str]) -> tuple[int, int]:
|
|||
"""Mark each given legacy thread id as imported. Idempotent.
|
||||
|
||||
Returns (accepted, inserted):
|
||||
- accepted: number of non-empty deduped input ids
|
||||
- inserted: number of rows that were actually new (not already in ledger)
|
||||
- accepted: count of non-empty deduped input ids
|
||||
- inserted: count of rows that were actually new (not already in ledger)
|
||||
|
||||
ON CONFLICT DO NOTHING keeps the existing imported_at when an id is
|
||||
recorded twice. INSERT...RETURNING reports only the rows that were
|
||||
actually inserted, so callers can distinguish first-time imports
|
||||
from idempotent re-runs without an extra SELECT.
|
||||
recorded twice. INSERT...RETURNING reports only newly-inserted rows, so
|
||||
callers distinguish first-time imports from idempotent re-runs without an
|
||||
extra SELECT.
|
||||
"""
|
||||
ids = list(dict.fromkeys(tid for tid in legacy_thread_ids if tid))
|
||||
if not ids:
|
||||
|
|
|
|||
|
|
@ -7,19 +7,19 @@ Shared pytest configuration for the backend test suite.
|
|||
Responsibilities:
|
||||
1. Put the backend root on sys.path so `from models.inference import ...`
|
||||
(and similar flat imports) resolve in test modules — mirrors how the
|
||||
app itself is launched.
|
||||
app is launched.
|
||||
2. Provide a hybrid ``studio_server`` session fixture for end-to-end tests
|
||||
(see ``test_studio_api.py``). The fixture supports two invocation modes:
|
||||
(see ``test_studio_api.py``), supporting two invocation modes:
|
||||
|
||||
a. **External server.** If ``UNSLOTH_E2E_BASE_URL`` is set, tests point
|
||||
at an already-running Studio instance. ``UNSLOTH_E2E_API_KEY`` must
|
||||
also be set. This is the fast-iteration mode: start the server once
|
||||
with ``unsloth studio run ...``, then run pytest against it many
|
||||
times with no per-run GGUF load cost.
|
||||
at an already-running Studio instance (``UNSLOTH_E2E_API_KEY`` must
|
||||
also be set). Fast-iteration mode: start the server once with
|
||||
``unsloth studio run ...``, then run pytest against it many times with
|
||||
no per-run GGUF load cost.
|
||||
|
||||
b. **Fixture-managed server.** Otherwise, the fixture launches a fresh
|
||||
server via ``_start_server`` and tears it down at session end. This
|
||||
is the one-shot mode for CI or a clean-slate verification run.
|
||||
b. **Fixture-managed server.** Otherwise the fixture launches a fresh
|
||||
server via ``_start_server`` and tears it down at session end. One-shot
|
||||
mode for CI or a clean-slate verification run.
|
||||
|
||||
The model / variant for mode (b) come from ``--unsloth-model`` /
|
||||
``--unsloth-gguf-variant`` pytest options, then ``UNSLOTH_E2E_MODEL`` /
|
||||
|
|
@ -80,16 +80,16 @@ def studio_server(request):
|
|||
|
||||
Resolution order:
|
||||
|
||||
1. If ``UNSLOTH_E2E_BASE_URL`` is set → point at that server,
|
||||
require ``UNSLOTH_E2E_API_KEY`` alongside (skip if missing).
|
||||
2. Otherwise → start a fresh ``unsloth studio run`` subprocess via
|
||||
the existing ``_start_server`` helper in ``test_studio_api.py``
|
||||
and tear it down on session teardown.
|
||||
1. If ``UNSLOTH_E2E_BASE_URL`` is set → point at that server, requiring
|
||||
``UNSLOTH_E2E_API_KEY`` alongside (skip if missing).
|
||||
2. Otherwise → start a fresh ``unsloth studio run`` subprocess via the
|
||||
``_start_server`` helper in ``test_studio_api.py`` and tear it down on
|
||||
session teardown.
|
||||
|
||||
Session-scoped so the expensive GGUF load happens at most once per
|
||||
pytest invocation. Lazily instantiated — tests that don't request
|
||||
the fixture (e.g. the unit tests in ``test_anthropic_messages.py``
|
||||
or ``test_help_output``) do not trigger server startup.
|
||||
Session-scoped so the expensive GGUF load happens at most once per pytest
|
||||
invocation. Lazy — tests that don't request the fixture (e.g. the unit
|
||||
tests in ``test_anthropic_messages.py`` or ``test_help_output``) don't
|
||||
trigger server startup.
|
||||
"""
|
||||
external_url = os.environ.get("UNSLOTH_E2E_BASE_URL")
|
||||
if external_url:
|
||||
|
|
@ -103,9 +103,9 @@ def studio_server(request):
|
|||
yield external_url, api_key
|
||||
return
|
||||
|
||||
# Lazy import: pytest has already loaded test_studio_api into
|
||||
# sys.modules by the time any test requests this fixture, so this
|
||||
# is a cache hit, not a re-execution.
|
||||
# Lazy import: pytest has already loaded test_studio_api into sys.modules
|
||||
# by the time any test requests this fixture, so this is a cache hit, not
|
||||
# a re-execution.
|
||||
import test_studio_api as _e2e
|
||||
|
||||
model = (
|
||||
|
|
|
|||
|
|
@ -1,18 +1,17 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Unit tests for the prompt_cache_ttl threading on the Anthropic path.
|
||||
"""Unit tests for prompt_cache_ttl threading on the Anthropic path.
|
||||
|
||||
Anthropic accepts an optional ``ttl`` on each ``cache_control`` marker:
|
||||
the default is the 5-minute ephemeral pool; ``ttl:"1h"`` writes into
|
||||
the 1-hour pool instead. The 1h pool is the right pick when
|
||||
conversations span multiple short bursts more than 5 minutes apart --
|
||||
1h writes are billed at 2x base input vs 1.25x for 5m, but reads stay
|
||||
at 0.1x for both, so one extra read pays off the premium.
|
||||
default is the 5-minute ephemeral pool; ``ttl:"1h"`` writes into the
|
||||
1-hour pool. The 1h pool fits conversations spanning short bursts more
|
||||
than 5 minutes apart -- 1h writes bill at 2x base input vs 1.25x for 5m,
|
||||
but reads stay at 0.1x for both, so one extra read pays the premium.
|
||||
|
||||
These tests pin the outbound body shape: when prompt_cache_ttl="1h"
|
||||
both cache_control markers carry ``ttl:"1h"``; default omits the field
|
||||
entirely so the 5m pool is used; garbage values are silently dropped.
|
||||
These tests pin the outbound body shape: prompt_cache_ttl="1h" puts
|
||||
``ttl:"1h"`` on both markers; default omits the field (5m pool); garbage
|
||||
values are silently dropped.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
|
@ -128,13 +127,12 @@ def test_1h_ttl_writes_into_1h_pool(monkeypatch):
|
|||
|
||||
|
||||
def test_1h_ttl_does_not_send_extended_cache_ttl_beta_header(monkeypatch):
|
||||
# The `extended-cache-ttl-2025-04-11` beta header that originally
|
||||
# gated 1h cache TTL has been promoted to GA: verified live against
|
||||
# api.anthropic.com on 2026-05-22 -- a request with
|
||||
# `cache_control:{type:"ephemeral", ttl:"1h"}` and NO beta header
|
||||
# returns 200 and populates `ephemeral_1h_input_tokens`. Pin the
|
||||
# contract so we don't reintroduce the gate by accident; a future
|
||||
# regression that re-adds the header would surface here.
|
||||
# The `extended-cache-ttl-2025-04-11` beta header that originally gated
|
||||
# 1h cache TTL is now GA: verified live against api.anthropic.com on
|
||||
# 2026-05-22 -- a request with `cache_control:{type:"ephemeral",
|
||||
# ttl:"1h"}` and NO beta header returns 200 and populates
|
||||
# `ephemeral_1h_input_tokens`. Pin the contract so a regression that
|
||||
# re-adds the header surfaces here.
|
||||
captured = _capture(monkeypatch, ttl = "1h")
|
||||
beta = captured["headers"].get("anthropic-beta", "")
|
||||
assert "extended-cache-ttl-2025-04-11" not in beta, beta
|
||||
|
|
@ -156,7 +154,7 @@ def test_unknown_ttl_silently_dropped(monkeypatch, bogus):
|
|||
assert len(ccs) == 2, ccs
|
||||
for cc in ccs:
|
||||
# Bogus TTLs must NOT round-trip; marker stays at the default
|
||||
# (no `ttl` key, which means the 5m pool upstream).
|
||||
# (no `ttl` key = 5m pool upstream).
|
||||
assert cc == {"type": "ephemeral"}, cc
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -3,11 +3,10 @@
|
|||
|
||||
"""Tests for Anthropic ``citations_delta`` handling in the streaming proxy.
|
||||
|
||||
Verifies the proxy injects inline ``[N]`` markers after cited text,
|
||||
dedupes by type-specific anchor (char_location, page_location,
|
||||
content_block_location, search_result_location), forwards a synthetic
|
||||
``document_citations`` tool_event at message_stop, and stays inert when
|
||||
no citations_delta events fire. See
|
||||
Verifies the proxy injects inline ``[N]`` markers after cited text, dedupes by
|
||||
type-specific anchor (char_location, page_location, content_block_location,
|
||||
search_result_location), forwards a synthetic ``document_citations`` tool_event
|
||||
at message_stop, and stays inert when no citations_delta events fire. See
|
||||
https://platform.claude.com/docs/en/build-with-claude/citations
|
||||
"""
|
||||
|
||||
|
|
|
|||
|
|
@ -50,10 +50,9 @@ def _capture(
|
|||
messages: list[dict] | None = None,
|
||||
captured_body: dict | None = None,
|
||||
) -> list[str]:
|
||||
"""Drive ``stream_chat_completion`` against a mocked Anthropic
|
||||
response and return the SSE lines. Pass ``captured_body`` to also
|
||||
capture the outgoing request body for assertions on the translated
|
||||
Anthropic shape.
|
||||
"""Drive ``stream_chat_completion`` against a mocked Anthropic response
|
||||
and return the SSE lines. Pass ``captured_body`` to also capture the
|
||||
outgoing request body for assertions on the translated Anthropic shape.
|
||||
"""
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
|
|
@ -147,8 +146,8 @@ def _joined(lines: list[str]) -> str:
|
|||
|
||||
|
||||
def _citation_payload(body: str) -> dict:
|
||||
"""Pull the ``document_citations`` synthetic tool_event from the
|
||||
SSE body and return its payload. Raises if absent."""
|
||||
"""Return the ``document_citations`` synthetic tool_event payload from
|
||||
the SSE body. Raises if absent."""
|
||||
assert "document_citations" in body, body
|
||||
for line in body.splitlines():
|
||||
if not line.startswith("data: "):
|
||||
|
|
@ -167,8 +166,8 @@ def _citation_payload(body: str) -> dict:
|
|||
|
||||
|
||||
def test_citation_with_no_preceding_text_still_emits_marker(monkeypatch):
|
||||
"""citations_delta before any text_delta must not crash; marker
|
||||
lands at the start of the block."""
|
||||
"""citations_delta before any text_delta must not crash; marker lands
|
||||
at the start of the block."""
|
||||
cit = {
|
||||
"type": "char_location",
|
||||
"document_index": 0,
|
||||
|
|
@ -195,8 +194,8 @@ def test_citation_with_no_preceding_text_still_emits_marker(monkeypatch):
|
|||
|
||||
|
||||
def test_citations_delta_with_non_dict_citation_is_ignored(monkeypatch):
|
||||
"""Non-dict ``delta.citation`` must not crash, emit a marker, or
|
||||
poison the document_citations list."""
|
||||
"""Non-dict ``delta.citation`` must not crash, emit a marker, or poison
|
||||
the document_citations list."""
|
||||
lines = _capture(
|
||||
monkeypatch,
|
||||
[
|
||||
|
|
@ -220,8 +219,8 @@ def test_citations_delta_with_non_dict_citation_is_ignored(monkeypatch):
|
|||
|
||||
|
||||
def test_citations_delta_with_missing_citation_field_is_ignored(monkeypatch):
|
||||
"""Missing ``citation`` field is treated like a non-dict citation:
|
||||
skip without crashing."""
|
||||
"""Missing ``citation`` field is treated like a non-dict citation: skip
|
||||
without crashing."""
|
||||
lines = _capture(
|
||||
monkeypatch,
|
||||
[
|
||||
|
|
@ -245,8 +244,8 @@ def test_citations_delta_with_missing_citation_field_is_ignored(monkeypatch):
|
|||
|
||||
|
||||
def test_char_location_with_reversed_indices_does_not_crash(monkeypatch):
|
||||
"""Malformed char_location with reversed indices must not crash;
|
||||
the dedup key accepts any int pair and still surfaces a footnote."""
|
||||
"""Malformed char_location with reversed indices must not crash; the
|
||||
dedup key accepts any int pair and still surfaces a footnote."""
|
||||
cit = {
|
||||
"type": "char_location",
|
||||
"document_index": 0,
|
||||
|
|
@ -275,8 +274,8 @@ def test_char_location_with_reversed_indices_does_not_crash(monkeypatch):
|
|||
|
||||
|
||||
def test_page_location_missing_document_index_does_not_crash(monkeypatch):
|
||||
"""page_location missing ``document_index`` still produces a
|
||||
footnote; dedup key falls back to ``None`` for the missing field."""
|
||||
"""page_location missing ``document_index`` still produces a footnote;
|
||||
dedup key falls back to ``None`` for the missing field."""
|
||||
cit = {
|
||||
"type": "page_location",
|
||||
"document_title": "Untitled PDF",
|
||||
|
|
@ -303,8 +302,8 @@ def test_page_location_missing_document_index_does_not_crash(monkeypatch):
|
|||
|
||||
|
||||
def test_content_block_location_with_non_int_block_index_does_not_crash(monkeypatch):
|
||||
"""content_block_location with string block indices must not crash;
|
||||
dedup key tolerates non-int values."""
|
||||
"""content_block_location with string block indices must not crash; dedup
|
||||
key tolerates non-int values."""
|
||||
cit = {
|
||||
"type": "content_block_location",
|
||||
"document_index": 0,
|
||||
|
|
@ -332,8 +331,8 @@ def test_content_block_location_with_non_int_block_index_does_not_crash(monkeypa
|
|||
|
||||
|
||||
def test_unknown_citation_type_falls_back_to_stringified_key(monkeypatch):
|
||||
"""Unknown citation ``type`` (forward-compat) still dedupes:
|
||||
identical ones collapse, differing ones get distinct numbers."""
|
||||
"""Unknown citation ``type`` (forward-compat) still dedupes: identical
|
||||
ones collapse, differing ones get distinct numbers."""
|
||||
cit_a = {
|
||||
"type": "future_shape_location",
|
||||
"anchor": "abc",
|
||||
|
|
@ -369,8 +368,8 @@ def test_unknown_citation_type_falls_back_to_stringified_key(monkeypatch):
|
|||
|
||||
|
||||
def test_mixed_citation_types_same_document_get_distinct_keys(monkeypatch):
|
||||
"""char_location and page_location on the same document_index are
|
||||
distinct shapes; dedup key uses citation type as its first slot."""
|
||||
"""char_location and page_location on the same document_index are distinct
|
||||
shapes; dedup key uses citation type as its first slot."""
|
||||
cit_char = {
|
||||
"type": "char_location",
|
||||
"document_index": 0,
|
||||
|
|
@ -406,9 +405,9 @@ def test_mixed_citation_types_same_document_get_distinct_keys(monkeypatch):
|
|||
|
||||
|
||||
def test_cited_text_is_preserved_in_synthetic_event(monkeypatch):
|
||||
"""``cited_text`` must survive into the synthetic event so the
|
||||
Sources panel can render it as a tooltip. Anthropic does not bill
|
||||
cited_text against output tokens, so preserving it is free."""
|
||||
"""``cited_text`` must survive into the synthetic event so the Sources
|
||||
panel can render it as a tooltip. Anthropic does not bill cited_text
|
||||
against output tokens, so preserving it is free."""
|
||||
cit = {
|
||||
"type": "char_location",
|
||||
"document_index": 0,
|
||||
|
|
@ -435,8 +434,8 @@ def test_cited_text_is_preserved_in_synthetic_event(monkeypatch):
|
|||
|
||||
|
||||
def test_internal_key_field_never_leaks_to_client(monkeypatch):
|
||||
"""The internal ``_key`` dedup sentinel must be stripped before
|
||||
the synthetic event is forwarded; it is not an Anthropic field."""
|
||||
"""The internal ``_key`` dedup sentinel must be stripped before the
|
||||
synthetic event is forwarded; it is not an Anthropic field."""
|
||||
cit = {
|
||||
"type": "char_location",
|
||||
"document_index": 0,
|
||||
|
|
@ -465,8 +464,8 @@ def test_internal_key_field_never_leaks_to_client(monkeypatch):
|
|||
|
||||
|
||||
def test_citation_across_multiple_content_blocks_numbers_continue(monkeypatch):
|
||||
"""Footnote numbering is per-message, not per-content-block:
|
||||
citations across separate blocks emit [1] then [2]."""
|
||||
"""Footnote numbering is per-message, not per-content-block: citations
|
||||
across separate blocks emit [1] then [2]."""
|
||||
cit_a = {
|
||||
"type": "char_location",
|
||||
"document_index": 0,
|
||||
|
|
@ -509,9 +508,9 @@ def test_citation_across_multiple_content_blocks_numbers_continue(monkeypatch):
|
|||
|
||||
|
||||
def test_inline_marker_lands_after_text_run(monkeypatch):
|
||||
"""Inline ``[N]`` must land AFTER the cited text run: Anthropic
|
||||
streams text then citation, so the proxy emits ``"...green.[1]"``
|
||||
not ``"[1]green"``."""
|
||||
"""Inline ``[N]`` must land AFTER the cited text run: Anthropic streams
|
||||
text then citation, so the proxy emits ``"...green.[1]"`` not
|
||||
``"[1]green"``."""
|
||||
cit = {
|
||||
"type": "char_location",
|
||||
"document_index": 0,
|
||||
|
|
@ -541,8 +540,8 @@ def test_inline_marker_lands_after_text_run(monkeypatch):
|
|||
|
||||
|
||||
def test_no_synthetic_event_when_only_text_deltas(monkeypatch):
|
||||
"""No citations_delta means no synthetic ``document_citations``
|
||||
event; Sources panel relies on absence to suppress the section."""
|
||||
"""No citations_delta means no synthetic ``document_citations`` event;
|
||||
Sources panel relies on absence to suppress the section."""
|
||||
lines = _capture(
|
||||
monkeypatch,
|
||||
[
|
||||
|
|
@ -561,9 +560,9 @@ def test_no_synthetic_event_when_only_text_deltas(monkeypatch):
|
|||
|
||||
|
||||
def test_input_document_translation_enables_citations(monkeypatch):
|
||||
"""``input_document`` must translate to an Anthropic ``document``
|
||||
block carrying ``citations: {enabled: true}`` (both base64 and url
|
||||
source branches) so upstream emits citations_delta."""
|
||||
"""``input_document`` must translate to an Anthropic ``document`` block
|
||||
carrying ``citations: {enabled: true}`` (both base64 and url source
|
||||
branches) so upstream emits citations_delta."""
|
||||
captured_b64: dict = {}
|
||||
_capture(
|
||||
monkeypatch,
|
||||
|
|
@ -631,8 +630,8 @@ def test_input_document_translation_enables_citations(monkeypatch):
|
|||
|
||||
|
||||
def test_cited_text_truncated_in_synthetic_event(monkeypatch):
|
||||
"""``cited_text`` is capped server-side so multi-KB spans do not
|
||||
balloon the SSE payload."""
|
||||
"""``cited_text`` is capped server-side so multi-KB spans don't balloon
|
||||
the SSE payload."""
|
||||
from core.inference.external_provider import _CITED_TEXT_MAX_LEN
|
||||
|
||||
long_quote = "x" * (_CITED_TEXT_MAX_LEN + 4000)
|
||||
|
|
|
|||
|
|
@ -6,20 +6,18 @@ Unit tests for Anthropic's server-side `code_execution_20250825` tool
|
|||
translation in `_stream_anthropic`.
|
||||
|
||||
Covers:
|
||||
- Request body: when ``enabled_tools=["code_execution"]``, the outbound
|
||||
``tools`` array carries ``{"type": "code_execution_20250825", "name":
|
||||
"code_execution"}`` and the ``anthropic-beta`` header includes
|
||||
``code-execution-2025-08-25``.
|
||||
- Combined request: ``enabled_tools=["web_search", "code_execution"]``
|
||||
sends both tool entries; the beta header still merges the code-exec
|
||||
flag onto whatever the registry contributed.
|
||||
- SSE translation: a `bash_code_execution` server_tool_use +
|
||||
- Request body: ``enabled_tools=["code_execution"]`` puts ``{"type":
|
||||
"code_execution_20250825", "name": "code_execution"}`` in the outbound
|
||||
``tools`` and ``code-execution-2025-08-25`` in the ``anthropic-beta``
|
||||
header.
|
||||
- Combined: ``["web_search", "code_execution"]`` sends both tool entries;
|
||||
the beta header merges the code-exec flag onto the registry's.
|
||||
- SSE: a `bash_code_execution` server_tool_use +
|
||||
`bash_code_execution_tool_result` pair emits one tool_start and one
|
||||
tool_end ``_toolEvent`` chunk with the expected arguments and result.
|
||||
- SSE translation: a `text_editor_code_execution` create + result emits
|
||||
a tool_start with ``kind="text_editor"`` + parsed args, and tool_end
|
||||
with ``"Created"`` (or ``"Updated"``) based on the ``is_file_update``
|
||||
flag.
|
||||
tool_end ``_toolEvent`` chunk with the expected args and result.
|
||||
- SSE: a `text_editor_code_execution` create + result emits a tool_start
|
||||
with ``kind="text_editor"`` + parsed args, and tool_end with
|
||||
``"Created"`` (or ``"Updated"``) per the ``is_file_update`` flag.
|
||||
- Error path: a ``bash_code_execution_tool_result_error`` with
|
||||
``error_code="container_expired"`` renders as ``"Error:
|
||||
container_expired"`` in the tool_end ``result``.
|
||||
|
|
@ -116,13 +114,13 @@ def test_code_execution_tool_appended_to_request_body(monkeypatch):
|
|||
|
||||
body = captured["body"]
|
||||
tools = body.get("tools") or []
|
||||
# Opus 4.7 gets the newer date-pinned variant (`_20260120`) that
|
||||
# supports REPL state persistence + programmatic tool calling.
|
||||
# Opus 4.7 gets the newer date-pinned variant (`_20260120`) with REPL
|
||||
# state persistence + programmatic tool calling.
|
||||
assert {"type": "code_execution_20260120", "name": "code_execution"} in tools
|
||||
# No web_search entry when only code_execution is enabled.
|
||||
assert all("web_search" not in (t.get("type") or "") for t in tools)
|
||||
# Beta header still carries the documented flag; both `_20250825`
|
||||
# and `_20260120` are unlocked by the same header per upstream docs.
|
||||
# Beta header still carries the flag; both `_20250825` and `_20260120`
|
||||
# are unlocked by the same header per upstream docs.
|
||||
beta_header = captured["headers"].get("anthropic-beta", "")
|
||||
assert "code-execution-2025-08-25" in beta_header
|
||||
|
||||
|
|
@ -193,11 +191,11 @@ def test_no_code_execution_tool_when_pill_off(monkeypatch):
|
|||
_drive(run())
|
||||
|
||||
tools = captured["body"].get("tools") or []
|
||||
# Pill off -- neither the legacy nor the new code_execution variant
|
||||
# may appear on the wire.
|
||||
# Pill off -- neither the legacy nor new code_execution variant may
|
||||
# appear on the wire.
|
||||
assert all("code_execution" not in (t.get("type") or "") for t in tools)
|
||||
# Beta header must NOT mention code-execution when the tool isn't on
|
||||
# -- that flag is opt-in only.
|
||||
# Beta header must NOT mention code-execution when the tool is off --
|
||||
# that flag is opt-in only.
|
||||
assert "code-execution-2025-08-25" not in captured["headers"].get("anthropic-beta", "")
|
||||
|
||||
|
||||
|
|
@ -270,8 +268,8 @@ def test_bash_code_execution_emits_tool_start_and_end(monkeypatch):
|
|||
assert start["type"] == "tool_start"
|
||||
assert start["tool_name"] == "code_execution"
|
||||
assert start["tool_call_id"] == "srvtoolu_1"
|
||||
# `_server_tool: True` marks this as a provider-side synthetic
|
||||
# tool card for the frontend's history serializer.
|
||||
# `_server_tool: True` marks a provider-side synthetic tool card for
|
||||
# the frontend's history serializer.
|
||||
assert start["arguments"] == {"kind": "bash", "command": "ls -la", "_server_tool": True}
|
||||
|
||||
assert end["type"] == "tool_end"
|
||||
|
|
|
|||
|
|
@ -4,15 +4,15 @@
|
|||
"""Unit tests for Anthropic server-side context compaction wiring.
|
||||
|
||||
Compaction is a beta feature (header ``compact-2026-01-12``) gated to
|
||||
Opus 4.6, Opus 4.7, Sonnet 4.6, and Mythos preview. When enabled,
|
||||
Studio attaches ``context_management.edits[{type:"compact_20260112",
|
||||
trigger:{type:"input_tokens", value:N}}]`` to the outbound body. The
|
||||
minimum upstream-accepted threshold is 50k tokens; lower values are
|
||||
clamped to 50k so the request doesn't 400.
|
||||
Opus 4.6, Opus 4.7, Sonnet 4.6, and Mythos preview. When enabled, Studio
|
||||
attaches ``context_management.edits[{type:"compact_20260112",
|
||||
trigger:{type:"input_tokens", value:N}}]`` to the outbound body. Minimum
|
||||
upstream threshold is 50k tokens; lower values are clamped to 50k so the
|
||||
request doesn't 400.
|
||||
|
||||
These tests pin: the body shape per model, the beta header merge with
|
||||
the existing code-execution beta, threshold clamping, and silent no-op
|
||||
on unsupported models.
|
||||
These tests pin: body shape per model, the beta header merge with the
|
||||
code-execution beta, threshold clamping, and silent no-op on unsupported
|
||||
models.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
|
@ -168,10 +168,9 @@ def test_omitted_threshold_no_body_field(monkeypatch):
|
|||
|
||||
|
||||
def test_chat_completion_request_accepts_sub_50k_compaction_threshold():
|
||||
# Codex P1 caught that ge=50_000 on the field caused FastAPI to
|
||||
# 422 the request before the in-helper clamp could fire. The
|
||||
# schema must accept any positive int and let _stream_anthropic
|
||||
# clamp upward.
|
||||
# Codex P1 caught that ge=50_000 on the field made FastAPI 422 the
|
||||
# request before the in-helper clamp could fire. The schema must
|
||||
# accept any positive int and let _stream_anthropic clamp upward.
|
||||
from models.inference import ChatCompletionRequest
|
||||
|
||||
req = ChatCompletionRequest.model_validate(
|
||||
|
|
@ -209,13 +208,12 @@ def test_chat_completion_request_accepts_sub_50k_compaction_threshold():
|
|||
|
||||
def test_message_delta_iterations_array_aggregates_compaction_tokens(monkeypatch, capsys):
|
||||
# When Anthropic compacts mid-stream, the SSE message_delta usage
|
||||
# payload carries `iterations: [{type:"compaction", ...}, ...]`.
|
||||
# The top-level input_tokens / output_tokens only account for the
|
||||
# `message` iteration, so the cost surface needs the compaction
|
||||
# totals exposed separately. The stream helper folds them into
|
||||
# last_usage as `compaction_input_tokens` / `compaction_output_tokens`
|
||||
# and surfaces them in the closing summary log so an operator can
|
||||
# eyeball "did compaction cost us 180k tokens this turn?".
|
||||
# carries `iterations: [{type:"compaction", ...}, ...]`. The top-level
|
||||
# input_tokens / output_tokens only cover the `message` iteration, so
|
||||
# compaction totals need to be exposed separately. The stream helper
|
||||
# folds them into last_usage as `compaction_input_tokens` /
|
||||
# `compaction_output_tokens` and surfaces them in the closing summary
|
||||
# log so an operator can see "did compaction cost us 180k tokens?".
|
||||
|
||||
def http_handler(request: httpx.Request) -> httpx.Response:
|
||||
body = (
|
||||
|
|
@ -258,8 +256,8 @@ def test_message_delta_iterations_array_aggregates_compaction_tokens(monkeypatch
|
|||
|
||||
_drive(run())
|
||||
|
||||
# structlog renders the closing summary through the stdlib bridge,
|
||||
# which lands on stdout. Capture and check the rendered line.
|
||||
# structlog renders the closing summary via the stdlib bridge onto
|
||||
# stdout. Capture and check the rendered line.
|
||||
out = capsys.readouterr().out
|
||||
summary = next(
|
||||
(line for line in out.splitlines() if "Anthropic stream complete" in line),
|
||||
|
|
@ -271,8 +269,8 @@ def test_message_delta_iterations_array_aggregates_compaction_tokens(monkeypatch
|
|||
|
||||
def test_message_delta_no_iterations_leaves_compaction_keys_unset(monkeypatch, capsys):
|
||||
# Re-applying a previous compaction block does NOT emit a fresh
|
||||
# iterations array. The helper must not invent compaction keys
|
||||
# in that case (would otherwise double-bill).
|
||||
# iterations array. The helper must not invent compaction keys then
|
||||
# (would otherwise double-bill).
|
||||
def http_handler(request: httpx.Request) -> httpx.Response:
|
||||
body = (
|
||||
b"event: message_delta\n"
|
||||
|
|
@ -331,19 +329,17 @@ def _async_collect(agen):
|
|||
|
||||
|
||||
def test_compaction_block_emitted_as_tool_event(monkeypatch):
|
||||
# Codex P1: once context_management is enabled and Anthropic runs
|
||||
# compaction during a turn, the response carries a
|
||||
# `{type:"compaction", content:"<summary>"}` block. The translator
|
||||
# must surface it so the chat-adapter can persist it onto the
|
||||
# assistant message; otherwise the next turn loses the state and
|
||||
# Anthropic re-compacts from scratch.
|
||||
# Codex P1: once context_management is enabled and Anthropic compacts
|
||||
# during a turn, the response carries a `{type:"compaction",
|
||||
# content:"<summary>"}` block. The translator must surface it so the
|
||||
# chat-adapter persists it onto the assistant message; otherwise the
|
||||
# next turn loses the state and Anthropic re-compacts from scratch.
|
||||
|
||||
def http_handler(request: httpx.Request) -> httpx.Response:
|
||||
# Anthropic ships compaction blocks as a content_block_start
|
||||
# with `type:"compaction"`, then either includes the summary
|
||||
# on that start event AND/OR streams it via text_delta events
|
||||
# on the same block index. Test the streamed-delta path since
|
||||
# it's the harder case.
|
||||
# Anthropic ships compaction blocks as a content_block_start with
|
||||
# `type:"compaction"`, then includes the summary on that start
|
||||
# event AND/OR streams it via text_delta events on the same block
|
||||
# index. Test the streamed-delta path since it's the harder case.
|
||||
body = (
|
||||
b"event: message_start\n"
|
||||
b'data: {"type":"message_start","message":{"usage":{}}}\n\n'
|
||||
|
|
@ -411,8 +407,8 @@ def test_compaction_block_emitted_as_tool_event(monkeypatch):
|
|||
except json.JSONDecodeError:
|
||||
continue
|
||||
# tool_event payloads ride inside chat.completion.chunk.choices[0].delta.content
|
||||
# as a JSON-encoded string. The simpler path: look for the
|
||||
# marker substring anywhere in the chunk.
|
||||
# as a JSON-encoded string. Simpler: look for the marker substring
|
||||
# anywhere in the chunk.
|
||||
if "compaction_block" in raw:
|
||||
events.append(raw)
|
||||
assert events, f"no compaction_block tool event found in {lines}"
|
||||
|
|
@ -445,10 +441,10 @@ def test_compaction_block_emitted_as_tool_event(monkeypatch):
|
|||
|
||||
|
||||
def test_compaction_block_round_trips_through_outbound_messages(monkeypatch):
|
||||
# Once the prior turn persisted a compaction block onto the
|
||||
# assistant message, the next turn's outbound body must forward
|
||||
# the {type:"compaction", content:"..."} block to Anthropic
|
||||
# verbatim so the API recognises the existing state.
|
||||
# Once the prior turn persisted a compaction block onto the assistant
|
||||
# message, the next turn's outbound body must forward the
|
||||
# {type:"compaction", content:"..."} block to Anthropic verbatim so the
|
||||
# API recognises the existing state.
|
||||
captured: dict = {}
|
||||
|
||||
def http_handler(request: httpx.Request) -> httpx.Response:
|
||||
|
|
@ -527,10 +523,10 @@ def test_compaction_content_part_accepted_by_chat_message_schema():
|
|||
|
||||
|
||||
def test_build_external_messages_passes_compaction_for_anthropic_only():
|
||||
# Compaction is an Anthropic-only synthetic content part. The
|
||||
# builder MUST gate it on provider_type=="anthropic"; every other
|
||||
# provider would 400 on the unknown content type via generic
|
||||
# /chat/completions passthrough (Codex P1 follow-up).
|
||||
# Compaction is an Anthropic-only synthetic content part. The builder
|
||||
# MUST gate it on provider_type=="anthropic"; every other provider
|
||||
# would 400 on the unknown content type via generic /chat/completions
|
||||
# passthrough (Codex P1 follow-up).
|
||||
from models.inference import ChatMessage
|
||||
from routes.inference import _build_external_messages
|
||||
|
||||
|
|
@ -554,7 +550,7 @@ def test_build_external_messages_passes_compaction_for_anthropic_only():
|
|||
|
||||
def test_build_external_messages_strips_compaction_for_non_anthropic_providers():
|
||||
# Provider switch (or reused history) hands compaction blocks to a
|
||||
# non-Anthropic provider. Those land on generic /chat/completions
|
||||
# non-Anthropic provider, landing on generic /chat/completions
|
||||
# passthrough where the unknown content type fails the upstream
|
||||
# validator. Builder must strip the part for every non-anthropic
|
||||
# provider, including OpenAI/DeepSeek/Mistral/Gemini/Kimi/OpenRouter.
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@
|
|||
|
||||
"""Tests for Anthropic fast-mode wiring and streaming refusal handling.
|
||||
|
||||
fast_mode=True on Opus 4.6/4.7 attaches the ``fast-mode-2026-02-01``
|
||||
beta header and sets ``speed: "fast"``; unsupported models drop both.
|
||||
Streaming ``stop_reason: "refusal"`` surfaces a user notice before the
|
||||
fast_mode=True on Opus 4.6/4.7 attaches the ``fast-mode-2026-02-01`` beta
|
||||
header and sets ``speed: "fast"``; unsupported models drop both. Streaming
|
||||
``stop_reason: "refusal"`` surfaces a user notice before the
|
||||
``content_filter`` finish chunk.
|
||||
https://platform.claude.com/docs/en/test-and-evaluate/strengthen-guardrails/handle-streaming-refusals
|
||||
"""
|
||||
|
|
@ -63,7 +63,7 @@ def _capture(
|
|||
sse: bytes = b"",
|
||||
**kwargs,
|
||||
) -> tuple[dict, list[str]]:
|
||||
"""Install a MockTransport, drive one streamed call, return body+lines."""
|
||||
"""Install a MockTransport, drive one streamed call; return body+lines."""
|
||||
captured: dict = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
|
|
@ -157,12 +157,12 @@ def test_refusal_emits_user_facing_notice_and_content_filter_finish(monkeypatch)
|
|||
|
||||
def test_refusal_emits_tool_event_for_chat_adapter_drop(monkeypatch):
|
||||
"""Refused turns emit an out-of-band `_toolEvent` that the chat-adapter
|
||||
latches into assistant `metadata.custom.anthropicRefusal`, driving
|
||||
the next-request prune. Tool event (not text) prevents spoofing.
|
||||
latches into assistant `metadata.custom.anthropicRefusal`, driving the
|
||||
next-request prune. Tool event (not text) prevents spoofing.
|
||||
"""
|
||||
_, lines = _capture(monkeypatch, sse = _refusal_sse())
|
||||
body = "\n".join(lines)
|
||||
assert '"_toolEvent": {"type": "anthropic_refusal"}' in body, body
|
||||
# Visible refusal text must not embed a sentinel that could spoof
|
||||
# a context reset if echoed by another assistant message.
|
||||
# Visible refusal text must not embed a sentinel that could spoof a
|
||||
# context reset if echoed by another assistant message.
|
||||
assert "studio:anthropic-refusal" not in body, body
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Edge-case coverage for the Anthropic fast-mode + refusal wiring.
|
||||
"""Edge-case coverage for Anthropic fast-mode + refusal wiring.
|
||||
|
||||
Complements ``test_anthropic_fast_mode_and_refusal.py`` (happy path)
|
||||
with dated snapshots, strict opt-in (future Opus families do not
|
||||
auto-enable), multi-beta header merging, refusal stream ordering, and
|
||||
the non-destruction guarantee for unset/None fast_mode.
|
||||
Complements ``test_anthropic_fast_mode_and_refusal.py`` (happy path) with
|
||||
dated snapshots, strict opt-in (future Opus families do not auto-enable),
|
||||
multi-beta header merging, refusal stream ordering, and the
|
||||
non-destruction guarantee for unset/None fast_mode.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
|
@ -128,7 +128,7 @@ def test_fast_mode_attaches_on_dated_opus_4_6_snapshot(monkeypatch):
|
|||
|
||||
# ──────────────────────────── strict opt-in semantics ────────────────────────────
|
||||
def test_fast_mode_does_not_auto_enable_on_future_opus_4_8(monkeypatch):
|
||||
"""Future ``claude-opus-4-8`` must not auto-enable; opt-in per family."""
|
||||
"""Future ``claude-opus-4-8`` must not auto-enable; per-family opt-in."""
|
||||
cap, _ = _capture(monkeypatch, fast_mode = True, model = "claude-opus-4-8")
|
||||
assert "speed" not in cap["body"], cap["body"]
|
||||
assert "fast-mode-2026-02-01" not in cap["headers"].get("anthropic-beta", "")
|
||||
|
|
@ -248,7 +248,7 @@ def test_fast_mode_unset_is_byte_identical_to_omitted(monkeypatch):
|
|||
_drive(run())
|
||||
|
||||
assert cap_none["body"] == captured["body"], (cap_none["body"], captured["body"])
|
||||
# Headers can vary by httpx-injected fields (host, connection); compare
|
||||
# Headers vary by httpx-injected fields (host, connection); compare
|
||||
# the load-bearing ones.
|
||||
for key in ("anthropic-version", "x-api-key", "content-type"):
|
||||
assert cap_none["headers"].get(key) == captured["headers"].get(key), key
|
||||
|
|
@ -323,8 +323,7 @@ def test_refusal_chunk_is_proper_openai_delta_shape(monkeypatch):
|
|||
assert notice_chunk is not None, lines
|
||||
choice = notice_chunk["choices"][0]
|
||||
assert "delta" in choice and "content" in choice["delta"], notice_chunk
|
||||
# Must NOT carry a finish_reason itself -- that comes on the next
|
||||
# chunk.
|
||||
# Must NOT carry a finish_reason itself -- that comes on the next chunk.
|
||||
assert choice.get("finish_reason") in (None,), notice_chunk
|
||||
# Refusal text is plain-spoken; no embedded sentinel.
|
||||
assert "studio:anthropic-refusal" not in choice["delta"]["content"]
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
"""
|
||||
Tests for the Anthropic Messages API schemas and translation layer.
|
||||
Tests for Anthropic Messages API schemas and translation layer.
|
||||
No running server or GPU required.
|
||||
"""
|
||||
|
||||
|
|
@ -1187,14 +1187,14 @@ class TestAnthropicRequestedStudioTools:
|
|||
|
||||
def test_bare_name_without_type_is_not_treated_as_server_tool(self):
|
||||
# Anthropic dispatches server tools by `type`; bare-name matching
|
||||
# would let a malformed client tool (e.g. user forgot input_schema)
|
||||
# silently flip the request into server-execution mode.
|
||||
# would let a malformed client tool (missing input_schema) silently
|
||||
# flip the request into server-execution mode.
|
||||
tools = [{"name": "python"}]
|
||||
assert _anthropic_requested_studio_tools(tools) == set()
|
||||
|
||||
def test_client_tool_named_python_is_not_misclassified(self):
|
||||
# input_schema is the client-tool discriminator; presence of it
|
||||
# must prevent the name from being treated as a Studio alias.
|
||||
# input_schema is the client-tool discriminator; its presence must
|
||||
# prevent the name from being treated as a Studio alias.
|
||||
tools = [
|
||||
{
|
||||
"name": "python",
|
||||
|
|
@ -1239,8 +1239,8 @@ class _ToolPathCalled(Exception):
|
|||
def _mock_backend(monkeypatch, **overrides):
|
||||
"""Install a minimal stub backend on routes.inference.
|
||||
|
||||
Generation methods raise sentinel exceptions so the caller can assert
|
||||
which path the route entered.
|
||||
Generation methods raise sentinels so the caller can assert which path
|
||||
the route entered.
|
||||
"""
|
||||
import routes.inference as inf_mod
|
||||
|
||||
|
|
@ -1299,10 +1299,10 @@ class TestAnthropicMessagesToolRouting:
|
|||
assert "Mixing Anthropic server tools" in exc.value.detail
|
||||
|
||||
def test_mixed_rejected_when_client_tool_name_collides_with_server_alias(self, monkeypatch):
|
||||
# Regression: a client tool sharing a name with a mapped server
|
||||
# tool (e.g. user defines their own "web_search") must still
|
||||
# trigger the mixed-mode 400 — the post-name filter would
|
||||
# otherwise drop the client tool and silently route to server-only.
|
||||
# Regression: a client tool sharing a name with a mapped server tool
|
||||
# (e.g. a custom "web_search") must still trigger the mixed-mode 400;
|
||||
# otherwise the post-name filter drops the client tool and silently
|
||||
# routes to server-only.
|
||||
_mock_backend(monkeypatch)
|
||||
payload = _basic_payload(
|
||||
tools = [
|
||||
|
|
@ -1329,10 +1329,10 @@ class TestAnthropicMessagesToolRouting:
|
|||
|
||||
def test_client_tool_missing_name_rejected_with_400(self, monkeypatch):
|
||||
# Regression: AnthropicTool.name was relaxed to Optional for server
|
||||
# tools, so a client-tool payload that has input_schema but omits
|
||||
# `name` (e.g. typo) now parses successfully but would be silently
|
||||
# dropped by anthropic_tools_to_openai, leaving the request with
|
||||
# tool calling disabled. Reject at the boundary instead.
|
||||
# tools, so a client-tool payload with input_schema but no `name`
|
||||
# (typo) now parses but would be silently dropped by
|
||||
# anthropic_tools_to_openai, leaving tool calling disabled. Reject at
|
||||
# the boundary instead.
|
||||
_mock_backend(monkeypatch)
|
||||
payload = _basic_payload(
|
||||
tools = [{"input_schema": {"type": "object"}}],
|
||||
|
|
@ -1346,7 +1346,7 @@ class TestAnthropicMessagesToolRouting:
|
|||
def test_client_tool_empty_name_rejected_with_400(self, monkeypatch):
|
||||
# Same silent-disable class as missing-name: `name: ""` passes the
|
||||
# isinstance check but is dropped by anthropic_tools_to_openai's
|
||||
# `if not name` guard. Reject at the boundary so the typo surfaces.
|
||||
# `if not name` guard. Reject at the boundary so the typo shows.
|
||||
_mock_backend(monkeypatch)
|
||||
payload = _basic_payload(
|
||||
tools = [{"name": "", "input_schema": {"type": "object"}}],
|
||||
|
|
@ -1358,10 +1358,10 @@ class TestAnthropicMessagesToolRouting:
|
|||
assert "name" in exc.value.detail
|
||||
|
||||
def test_alias_named_client_tool_without_schema_rejected_with_400(self, monkeypatch):
|
||||
# Regression: a typo'd client tool whose name happens to collide
|
||||
# with a Studio alias (e.g. user meant a custom "python" tool but
|
||||
# forgot input_schema) must surface a 400, not silently switch
|
||||
# the request into Studio's built-in python execution.
|
||||
# Regression: a typo'd client tool whose name collides with a Studio
|
||||
# alias (e.g. a custom "python" tool missing input_schema) must
|
||||
# surface a 400, not silently switch into Studio's built-in python
|
||||
# execution.
|
||||
_mock_backend(monkeypatch)
|
||||
payload = _basic_payload(tools = [{"name": "python"}])
|
||||
|
||||
|
|
@ -1380,9 +1380,8 @@ class TestAnthropicMessagesToolRouting:
|
|||
_drive(anthropic_messages(payload, request = None, current_subject = "t"))
|
||||
|
||||
def test_disable_tools_policy_overrides_server_tool_alias(self, monkeypatch):
|
||||
# CLI `unsloth run --disable-tools` sets policy=False. A request
|
||||
# carrying a Studio server-tool alias must NOT enter the agentic
|
||||
# loop in that configuration.
|
||||
# CLI `unsloth run --disable-tools` sets policy=False. A request with
|
||||
# a Studio server-tool alias must NOT enter the agentic loop then.
|
||||
_mock_backend(monkeypatch)
|
||||
set_tool_policy(False)
|
||||
payload = _basic_payload(
|
||||
|
|
|
|||
|
|
@ -2,23 +2,19 @@
|
|||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""
|
||||
Unit tests for the Anthropic extended-thinking translation in
|
||||
external_provider.
|
||||
Unit tests for the Anthropic extended-thinking translation in external_provider.
|
||||
|
||||
Covers:
|
||||
- Adaptive-mode request body nests effort under
|
||||
``output_config: {effort: "<level>"}`` per the Messages API
|
||||
reference (a top-level ``effort`` field 400s with
|
||||
"effort: Extra inputs are not permitted").
|
||||
- Streaming SSE: ``content_block_delta`` with
|
||||
``delta.type == "thinking_delta"`` is translated into inline
|
||||
``<think>...</think>`` chat-completion chunks so the frontend's
|
||||
reasoning-panel pipeline lifts it correctly.
|
||||
- The ``<think>`` tag closes when the first ``text_delta`` arrives,
|
||||
on ``content_block_stop``, on ``message_delta``, or on
|
||||
``message_stop``.
|
||||
- Thinking is paired with ``temperature=1`` and no ``top_p`` /
|
||||
``top_k`` on the wire (Anthropic extended-thinking contract).
|
||||
``output_config: {effort: "<level>"}`` per the Messages API reference (a
|
||||
top-level ``effort`` field 400s with "effort: Extra inputs are not permitted").
|
||||
- Streaming SSE: ``content_block_delta`` with ``delta.type == "thinking_delta"``
|
||||
is translated into inline ``<think>...</think>`` chat-completion chunks so the
|
||||
frontend's reasoning-panel pipeline lifts it correctly.
|
||||
- The ``<think>`` tag closes when the first ``text_delta`` arrives, on
|
||||
``content_block_stop``, on ``message_delta``, or on ``message_stop``.
|
||||
- Thinking is paired with ``temperature=1`` and no ``top_p`` / ``top_k`` on the
|
||||
wire (Anthropic extended-thinking contract).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
|
@ -113,9 +109,8 @@ def test_adaptive_thinking_body_uses_output_config_effort_shape(monkeypatch):
|
|||
# display=summarized is set explicitly so Opus 4.7 (which defaults to
|
||||
# "omitted") still emits thinking_delta events for the reasoning panel.
|
||||
assert body["thinking"] == {"type": "adaptive", "display": "summarized"}
|
||||
# Documented shape: effort is nested under output_config.
|
||||
# A top-level `effort` field produces a 400:
|
||||
# "effort: Extra inputs are not permitted".
|
||||
# Documented shape: effort is nested under output_config. A top-level
|
||||
# `effort` field produces a 400: "effort: Extra inputs are not permitted".
|
||||
assert body["output_config"] == {"effort": "medium"}
|
||||
assert "effort" not in body
|
||||
# Extended-thinking contract: temperature=1, no top_p / top_k.
|
||||
|
|
@ -258,10 +253,10 @@ def test_manual_thinking_body_uses_budget_tokens_on_4_5(monkeypatch):
|
|||
body = captured["body"]
|
||||
assert body["thinking"] == {"type": "enabled", "budget_tokens": 4096}
|
||||
# max_tokens must be strictly greater than budget_tokens; we shipped 1024
|
||||
# and budget is 4096, so the wrapper should bump max_tokens.
|
||||
# and budget is 4096, so the wrapper must bump max_tokens.
|
||||
assert body["max_tokens"] > body["thinking"]["budget_tokens"]
|
||||
# Manual-thinking path does not use output_config / effort — those are
|
||||
# the adaptive-mode controls (Claude 4.6 / 4.7).
|
||||
# Manual-thinking path does not use output_config / effort — those are the
|
||||
# adaptive-mode controls (Claude 4.6 / 4.7).
|
||||
assert "effort" not in body
|
||||
assert "output_config" not in body
|
||||
|
||||
|
|
@ -338,8 +333,8 @@ def test_thinking_delta_wrapped_in_think_tags(monkeypatch):
|
|||
if isinstance(p, dict) and p["choices"][0]["delta"]
|
||||
)
|
||||
|
||||
# Reasoning text should be wrapped in <think>...</think>, followed by the
|
||||
# answer text, and the stream should terminate with [DONE].
|
||||
# Reasoning text is wrapped in <think>...</think>, then the answer text, and
|
||||
# the stream terminates with [DONE].
|
||||
assert "<think>First I plan.</think>" in combined
|
||||
assert combined.endswith("Answer.")
|
||||
# signature_delta is intentionally dropped — no leaked signature text.
|
||||
|
|
@ -350,9 +345,9 @@ def test_thinking_delta_wrapped_in_think_tags(monkeypatch):
|
|||
def test_thinking_only_turn_closes_tag_without_text_delta(monkeypatch):
|
||||
"""display=omitted on Claude 4.7 emits a signature_delta and no text.
|
||||
|
||||
The <think> open is still triggered by the (synthetic) thinking_delta;
|
||||
we want content_block_stop to close it cleanly so the tag never leaks
|
||||
into the next chunk."""
|
||||
The <think> open is still triggered by the (synthetic) thinking_delta; we
|
||||
want content_block_stop to close it cleanly so the tag never leaks into the
|
||||
next chunk."""
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
events = [
|
||||
|
|
|
|||
|
|
@ -7,13 +7,12 @@ helpers in ``core.inference.external_provider``.
|
|||
|
||||
Anthropic ships date-pinned tool versions per model family. The newer
|
||||
``_20260209`` web_search / web_fetch and ``_20260120`` code_execution
|
||||
variants only run on a subset of models; sending them to an older
|
||||
model returns a 400 from upstream, and sending the older
|
||||
``_20250305`` / ``_20250910`` / ``_20250825`` variants to a newer
|
||||
model misses dynamic filtering and the free-when-paired pricing. The
|
||||
helpers below decide which version goes out per model; this test pins
|
||||
the dispatch matrix so future model launches keep working without
|
||||
silently regressing the newer-version path.
|
||||
variants only run on a subset of models; sending them to an older model
|
||||
returns a 400 from upstream, and sending the older ``_20250305`` /
|
||||
``_20250910`` / ``_20250825`` variants to a newer model misses dynamic
|
||||
filtering and the free-when-paired pricing. The helpers below pick the
|
||||
version per model; this test pins the dispatch matrix so future model
|
||||
launches keep working without regressing the newer-version path.
|
||||
|
||||
Covers:
|
||||
- ``_anthropic_web_search_version`` / ``_anthropic_web_fetch_version``
|
||||
|
|
@ -23,10 +22,10 @@ Covers:
|
|||
- ``_anthropic_code_execution_version`` picks ``_20260120`` for the
|
||||
Opus 4.5+ / Sonnet 4.5+ / Opus 4.7 / Sonnet 4.6 family and falls back
|
||||
to ``_20250825`` everywhere else (Haiku 4.5, 4.1, 4.0).
|
||||
- ``_stream_anthropic`` body integration: when ``enabled_tools=
|
||||
["web_search", "code_execution"]`` is set on Opus 4.7, the outbound
|
||||
body carries the newer pinned versions; the same payload on Haiku
|
||||
4.5 falls back to the legacy versions.
|
||||
- ``_stream_anthropic`` body integration: with ``enabled_tools=
|
||||
["web_search", "code_execution"]`` on Opus 4.7, the outbound body
|
||||
carries the newer pinned versions; the same payload on Haiku 4.5 falls
|
||||
back to the legacy versions.
|
||||
- The ``anthropic-beta: code-execution-2025-08-25`` header is sent
|
||||
unchanged for both code-execution variants (no header rev needed).
|
||||
"""
|
||||
|
|
@ -165,9 +164,9 @@ def test_outbound_body_uses_new_versions_on_opus_4_7(monkeypatch):
|
|||
assert "code_execution_20260120" in tool_types
|
||||
assert "web_search_20250305" not in tool_types
|
||||
assert "code_execution_20250825" not in tool_types
|
||||
# Beta header for code execution stays on the existing flag for
|
||||
# both _20250825 and _20260120; the API uses one header to gate
|
||||
# the feature, not the date.
|
||||
# Beta header for code execution stays on the existing flag for both
|
||||
# _20250825 and _20260120; the API gates the feature by one header, not
|
||||
# the date.
|
||||
assert "code-execution-2025-08-25" in captured["headers"].get("anthropic-beta", "")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""
|
||||
Unit tests for Anthropic's `web_fetch_20250910` / `web_fetch_20260209`
|
||||
"""Unit tests for Anthropic's `web_fetch_20250910` / `web_fetch_20260209`
|
||||
translation in ``_stream_anthropic``. Covers request body emission
|
||||
(version picked by ``_anthropic_web_fetch_version``: ``_20260209`` for
|
||||
Opus 4.6/4.7 + Sonnet 4.6, ``_20250910`` otherwise), combined tool
|
||||
requests, off-by-default behavior, and SSE translation of success and
|
||||
(version from ``_anthropic_web_fetch_version``: ``_20260209`` for Opus
|
||||
4.6/4.7 + Sonnet 4.6, else ``_20250910``), combined tool requests,
|
||||
off-by-default behavior, and SSE translation of success and
|
||||
``url_not_accessible`` error paths into ``tool_start`` / ``tool_end``.
|
||||
"""
|
||||
|
||||
|
|
@ -103,7 +102,7 @@ def test_web_fetch_tool_appended_to_request_body(monkeypatch):
|
|||
|
||||
body = captured["body"]
|
||||
tools = body.get("tools") or []
|
||||
# claude-opus-4-7 routes web_fetch to _20260209 (dynamic filtering).
|
||||
# claude-opus-4-7 routes web_fetch to _20260209.
|
||||
assert {"type": "web_fetch_20260209", "name": "web_fetch", "max_uses": 5} in tools
|
||||
# web_fetch is GA; no beta header is required.
|
||||
assert "web-fetch" not in captured["headers"].get("anthropic-beta", "")
|
||||
|
|
@ -140,13 +139,13 @@ def test_web_fetch_combined_with_web_search_and_code_execution(monkeypatch):
|
|||
|
||||
tools = captured["body"].get("tools") or []
|
||||
tool_types = [t.get("type") for t in tools]
|
||||
# claude-opus-4-7 routes web_search and web_fetch to _20260209
|
||||
# and code_execution to _20260120 (per PR 5679 dispatch).
|
||||
# claude-opus-4-7 routes web_search/web_fetch to _20260209 and
|
||||
# code_execution to _20260120 (per PR 5679 dispatch).
|
||||
assert "web_search_20260209" in tool_types, tool_types
|
||||
assert "web_fetch_20260209" in tool_types, tool_types
|
||||
assert "code_execution_20260120" in tool_types, tool_types
|
||||
# Code-execution still adds its beta flag; web_fetch must not
|
||||
# have accidentally stripped it.
|
||||
# Code-execution still adds its beta flag; web_fetch must not have
|
||||
# stripped it.
|
||||
assert "code-execution-2025-08-25" in captured["headers"].get("anthropic-beta", "")
|
||||
|
||||
|
||||
|
|
@ -263,8 +262,8 @@ def test_web_fetch_success_emits_tool_start_and_end(monkeypatch):
|
|||
assert start["type"] == "tool_start"
|
||||
assert start["tool_name"] == "web_fetch"
|
||||
assert start["tool_call_id"] == "srvtoolu_wf1"
|
||||
# `_server_tool: True` marks this as a provider-side synthetic
|
||||
# tool card for the frontend's history serializer.
|
||||
# `_server_tool: True` marks this a provider-side synthetic tool card
|
||||
# for the frontend's history serializer.
|
||||
assert start["arguments"] == {"url": "https://example.com/article", "_server_tool": True}
|
||||
assert end["type"] == "tool_end"
|
||||
assert end["tool_call_id"] == "srvtoolu_wf1"
|
||||
|
|
@ -345,9 +344,10 @@ def test_web_fetch_error_renders_error_code(monkeypatch):
|
|||
|
||||
|
||||
def _finish_reasons(lines: list[str]) -> list:
|
||||
"""Return non-null finish_reason fields from each chat.completion.chunk.
|
||||
Mid-stream content deltas carry ``finish_reason: None`` and are skipped
|
||||
(the refusal path emits a notice delta before the content_filter chunk)."""
|
||||
"""Non-null finish_reason fields from each chat.completion.chunk.
|
||||
Mid-stream content deltas carry ``finish_reason: None`` and are
|
||||
skipped (refusal emits a notice delta before the content_filter
|
||||
chunk)."""
|
||||
out: list = []
|
||||
for line in lines:
|
||||
if not line.startswith("data:"):
|
||||
|
|
@ -369,12 +369,12 @@ def _finish_reasons(lines: list[str]) -> list:
|
|||
|
||||
|
||||
def test_pause_turn_does_not_emit_finish_reason_chunk(monkeypatch):
|
||||
# `pause_turn` is what Anthropic emits when a long server-tool turn
|
||||
# (typically web_search / web_fetch) pauses and will resume on the
|
||||
# next request. Treating it as finish_reason="stop" makes the
|
||||
# OpenAI-formatted client truncate the rendered assistant message.
|
||||
# The adapter must skip the chunk so the stream ends cleanly with
|
||||
# [DONE] and no terminal finish_reason.
|
||||
# Anthropic emits `pause_turn` when a long server-tool turn
|
||||
# (typically web_search / web_fetch) pauses and resumes on the next
|
||||
# request. Mapping it to finish_reason="stop" makes the
|
||||
# OpenAI-formatted client truncate the assistant message. The adapter
|
||||
# must skip the chunk so the stream ends cleanly with [DONE] and no
|
||||
# terminal finish_reason.
|
||||
sse_events = [
|
||||
{"type": "message_start", "message": {"usage": {}}},
|
||||
{
|
||||
|
|
@ -408,15 +408,14 @@ def test_pause_turn_does_not_emit_finish_reason_chunk(monkeypatch):
|
|||
)
|
||||
|
||||
lines = _drive(run())
|
||||
# No finish_reason chunk for pause_turn -- the only completion
|
||||
# signal is the [DONE] line.
|
||||
# No finish_reason chunk for pause_turn -- only [DONE] signals
|
||||
# completion.
|
||||
assert _finish_reasons(lines) == [], lines
|
||||
assert any(line.strip() == "data: [DONE]" for line in lines), lines
|
||||
|
||||
|
||||
def test_end_turn_still_emits_stop_finish_reason(monkeypatch):
|
||||
# Sanity: the pause_turn -> None mapping must not regress normal
|
||||
# end_turn handling.
|
||||
# Sanity: pause_turn -> None mapping must not regress end_turn.
|
||||
sse_events = [
|
||||
{"type": "message_start", "message": {"usage": {}}},
|
||||
{
|
||||
|
|
@ -489,12 +488,11 @@ def test_refusal_maps_to_content_filter(monkeypatch):
|
|||
|
||||
|
||||
def test_web_fetch_titleless_document_falls_back_to_url(monkeypatch):
|
||||
# Anthropic may omit `document.title` on pages where the HTML
|
||||
# provides nothing usable. Without a fallback the formatter would
|
||||
# emit `URL: ...\nSnippet: ...` only, and the frontend's
|
||||
# parseSourcesFromResult skips entries that lack a `Title:` line,
|
||||
# so the source pill silently disappears. Verify the formatter
|
||||
# mirrors the web_search behaviour and falls back to the URL.
|
||||
# Anthropic may omit `document.title` when the HTML has nothing
|
||||
# usable. Without a fallback the formatter emits `URL: ...\nSnippet:
|
||||
# ...` only, and the frontend's parseSourcesFromResult skips entries
|
||||
# lacking a `Title:` line, so the source pill disappears. Verify the
|
||||
# formatter mirrors web_search and falls back to the URL.
|
||||
sse_events = [
|
||||
{"type": "message_start", "message": {"usage": {}}},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Tests for tokenizer-based audio_type detection patterns, covering both
|
||||
Gemma 3n (<audio_soft_token>) and Gemma 4 (<|audio|>) audio-input tokens."""
|
||||
"""Tests for tokenizer-based audio_type detection, covering Gemma 3n
|
||||
(<audio_soft_token>) and Gemma 4 (<|audio|>) audio-input tokens."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -10,8 +10,7 @@ from utils.models.model_config import _AUDIO_TOKEN_PATTERNS, is_audio_input_type
|
|||
|
||||
|
||||
def _classify(tokens: list[str]) -> str | None:
|
||||
"""Mirror _detect_audio_from_tokenizer._check_token_patterns: first match
|
||||
in dict order wins."""
|
||||
"""Mirror _check_token_patterns: first match in dict order wins."""
|
||||
for audio_type, check in _AUDIO_TOKEN_PATTERNS.items():
|
||||
if check(tokens):
|
||||
return audio_type
|
||||
|
|
|
|||
|
|
@ -9,8 +9,7 @@ from pathlib import Path
|
|||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
# Keep this test runnable in lightweight environments where optional logging
|
||||
# deps are not installed.
|
||||
# Keep runnable in lightweight environments lacking optional logging deps.
|
||||
if "structlog" not in sys.modules:
|
||||
|
||||
class _DummyLogger:
|
||||
|
|
|
|||
|
|
@ -111,10 +111,10 @@ def test_resolve_cached_repo_id_case_late_cache_population(tmp_path, monkeypatch
|
|||
first = resolve_cached_repo_id_case("org/model")
|
||||
assert first == "org/model"
|
||||
|
||||
# Simulate cache being populated after first miss (e.g. another code path/download).
|
||||
# Cache populated after first miss (e.g. another code path/download).
|
||||
_mk_cache_repo(tmp_path, "Org/Model")
|
||||
|
||||
second = resolve_cached_repo_id_case("org/model")
|
||||
|
||||
# Desired behavior: second lookup should pick up the now-existing variant.
|
||||
# Second lookup should pick up the now-existing variant.
|
||||
assert second == "Org/Model"
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ import types
|
|||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
# Keep this test runnable in lightweight environments where optional logging
|
||||
# deps are not installed.
|
||||
# Keep this test runnable in lightweight environments without optional
|
||||
# logging deps.
|
||||
if "structlog" not in sys.modules:
|
||||
|
||||
class _DummyLogger:
|
||||
|
|
@ -212,7 +212,7 @@ def test_list_cached_models_skips_non_suffix_repo_when_gguf_files_exist(monkeypa
|
|||
|
||||
def test_list_cached_gguf_includes_mixed_repo_with_gguf_and_safetensors(monkeypatch, tmp_path):
|
||||
"""Mirror of the _skips_ test: the mixed repo should still surface in
|
||||
cached-gguf so the picker can show it as a GGUF download."""
|
||||
cached-gguf so the picker shows it as a GGUF download."""
|
||||
mixed = _repo(
|
||||
"Org/MixedRepo",
|
||||
[
|
||||
|
|
@ -241,8 +241,8 @@ def test_list_cached_gguf_includes_mixed_repo_with_gguf_and_safetensors(monkeypa
|
|||
|
||||
def test_list_cached_gguf_handles_none_size_on_disk(monkeypatch, tmp_path):
|
||||
"""A partial/interrupted GGUF download has ``size_on_disk = None``. The
|
||||
route must treat the unknown bytes as zero instead of raising TypeError
|
||||
out of ``sum()`` and wiping the entire response."""
|
||||
route must treat unknown bytes as zero instead of raising TypeError from
|
||||
``sum()`` and wiping the whole response."""
|
||||
partial = _repo(
|
||||
"Org/PartialDownload",
|
||||
[_file("Q4_K_M.gguf", None), _file("Q6_K.gguf", 5_000)],
|
||||
|
|
@ -268,7 +268,7 @@ def test_list_cached_gguf_handles_none_size_on_disk(monkeypatch, tmp_path):
|
|||
|
||||
def test_list_cached_gguf_skips_malformed_repo_without_wiping_response(monkeypatch, tmp_path):
|
||||
"""One repo raising during classification must not poison the response
|
||||
for every other repo in the scan."""
|
||||
for the other repos in the scan."""
|
||||
|
||||
class _ExplodingRepo:
|
||||
repo_id = "Org/Broken"
|
||||
|
|
@ -303,9 +303,9 @@ def test_list_cached_gguf_skips_malformed_repo_without_wiping_response(monkeypat
|
|||
|
||||
|
||||
def test_list_cached_gguf_skips_repo_with_only_mmproj_gguf(monkeypatch, tmp_path):
|
||||
"""A repo whose only ``.gguf`` artifact is an mmproj vision adapter
|
||||
must not be classified as a GGUF repo: the variant selector filters
|
||||
mmproj out and the picker would otherwise show zero variants."""
|
||||
"""A repo whose only ``.gguf`` artifact is an mmproj vision adapter must
|
||||
not be classified as a GGUF repo: the variant selector filters mmproj
|
||||
out and the picker would otherwise show zero variants."""
|
||||
mmproj_only = _repo(
|
||||
"Org/MmprojOnly",
|
||||
[
|
||||
|
|
@ -327,9 +327,9 @@ def test_list_cached_gguf_skips_repo_with_only_mmproj_gguf(monkeypatch, tmp_path
|
|||
|
||||
|
||||
def test_list_cached_models_includes_repo_with_only_mmproj_gguf(monkeypatch, tmp_path):
|
||||
"""Mirror of the cached-gguf skip: a safetensors repo with an
|
||||
auxiliary mmproj vision adapter must still surface in cached-models
|
||||
so the user can load it as a normal model."""
|
||||
"""Mirror of the cached-gguf skip: a safetensors repo with an auxiliary
|
||||
mmproj vision adapter must still surface in cached-models so the user
|
||||
can load it as a normal model."""
|
||||
mmproj_aux = _repo(
|
||||
"Org/MmprojAux",
|
||||
[
|
||||
|
|
@ -351,10 +351,10 @@ def test_list_cached_models_includes_repo_with_only_mmproj_gguf(monkeypatch, tmp
|
|||
|
||||
|
||||
def test_list_cached_gguf_includes_vision_repo_with_main_gguf_and_mmproj(monkeypatch, tmp_path):
|
||||
"""A vision-capable GGUF repo (main weight + mmproj adapter) is still
|
||||
a GGUF repo. The reported size is the main weight size; mmproj is
|
||||
excluded from the GGUF-size accounting because it is filtered out at
|
||||
classification time."""
|
||||
"""A vision-capable GGUF repo (main weight + mmproj adapter) is still a
|
||||
GGUF repo. Reported size is the main weight size; mmproj is excluded
|
||||
from GGUF-size accounting since it is filtered out at classification
|
||||
time."""
|
||||
vision_repo = _repo(
|
||||
"Org/VisionGguf",
|
||||
[
|
||||
|
|
|
|||
|
|
@ -30,9 +30,9 @@ def _reset_studio_db(
|
|||
def workspace_projects_home(tmp_path):
|
||||
"""Projects root outside the platform delete denylist.
|
||||
|
||||
tmp_path resolves under /private/tmp on macOS, which the workspace
|
||||
delete guard refuses by design. Linux/Windows tmp is not denied and is
|
||||
used as-is; only the denied case falls back to a home subdir.
|
||||
tmp_path resolves under /private/tmp on macOS, which the workspace delete
|
||||
guard refuses by design. Linux/Windows tmp is not denied and is used as-is;
|
||||
only the denied case falls back to a home subdir.
|
||||
"""
|
||||
candidate = tmp_path / "Projects"
|
||||
resolved = str(candidate.resolve())
|
||||
|
|
@ -367,8 +367,8 @@ def test_legacy_imports_dedups_input(tmp_path, monkeypatch):
|
|||
accepted, inserted = studio_db.upsert_chat_legacy_imports(
|
||||
["x", "x", "y", "x"],
|
||||
)
|
||||
# accepted is the deduped non-empty input size; inserted is the rows
|
||||
# actually new in the ledger after ON CONFLICT DO NOTHING.
|
||||
# accepted is the deduped non-empty input size; inserted is the rows newly
|
||||
# added to the ledger after ON CONFLICT DO NOTHING.
|
||||
assert accepted == 2
|
||||
assert inserted == 2
|
||||
assert set(studio_db.list_chat_legacy_imports()) == {"x", "y"}
|
||||
|
|
|
|||
|
|
@ -16,11 +16,11 @@ if str(_BACKEND_ROOT) not in sys.path:
|
|||
|
||||
@pytest.fixture
|
||||
def outputs_setup(tmp_path, monkeypatch):
|
||||
"""Point outputs_root() at a temp dir so cleanup is allowed to run on it.
|
||||
"""Point outputs_root() at a temp dir so cleanup may run on it.
|
||||
|
||||
The training module binds ``outputs_root`` at import time
|
||||
(``from utils.paths import outputs_root``), so we have to patch
|
||||
the symbol on the importer module, not on storage_roots.
|
||||
(``from utils.paths import outputs_root``), so patch the symbol on the
|
||||
importer module, not on storage_roots.
|
||||
"""
|
||||
from core.training import training as training_mod
|
||||
|
||||
|
|
@ -36,8 +36,8 @@ def _mk_dir(parent: Path, name: str) -> Path:
|
|||
|
||||
|
||||
def test_completed_checkpoints_are_preserved(outputs_setup):
|
||||
"""The big regression: prior to this fix, every completed
|
||||
checkpoint-N/ was rmtree'd on Cancel, destroying resume points."""
|
||||
"""The big regression: before this fix, every completed checkpoint-N/
|
||||
was rmtree'd on Cancel, destroying resume points."""
|
||||
from core.training.training import _cleanup_cancelled_checkpoints
|
||||
|
||||
out = outputs_setup / "run-1"
|
||||
|
|
|
|||
|
|
@ -116,8 +116,8 @@ def _ast_line_of_platform_compat_import(source: str) -> int:
|
|||
raise AssertionError("_platform_compat import not found")
|
||||
|
||||
|
||||
# AST-based ordering: configure_cpu_threads() must precede _platform_compat
|
||||
# in both run.py and main.py. Robust to formatting / line shifts.
|
||||
# AST ordering: configure_cpu_threads() must precede _platform_compat in both
|
||||
# run.py and main.py. Robust to formatting / line shifts.
|
||||
@pytest.mark.parametrize("entry_point", [_RUN_PY, _MAIN_PY])
|
||||
def test_cpu_thread_configuration_runs_before_backend_imports(entry_point):
|
||||
source = entry_point.read_text()
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue