* Studio: offer the latest transformers release for brand-new architectures When a model's config.json model_type is absent from every installed transformers overlay (base 4.57.x and the .venv_t5_530/550/510 sidecars), Studio now checks, unauthenticated and cached, whether the newest transformers ships it: - utils/transformers_latest.py fetches the latest release version from https://pypi.org/pypi/transformers/json and the CONFIG_MAPPING_NAMES sources for that tag and for main from raw.githubusercontent.com (never api.github.com), parsing them with the same AST extractor the static router uses (no code execution, no trust_remote_code). Results are cached in memory and in a JSON snapshot under studio_root()/cache with a one day ttl; fetches are bounded to 5s with one retry and a failure backoff, and offline mode or the new kill switch UNSLOTH_STUDIO_NO_LATEST_TRANSFORMERS=1 short-circuits to None. - POST /api/inference/validate gains requires_transformers_upgrade plus a transformers_upgrade payload (model_type, pypi_version, supported_in_pypi, supported_in_main) so the frontend can raise the install consent dialog before /load, mirroring the existing remote-code consent flow. The check fires only when the model_type is unknown to all installed overlays and the hardcoded tier tables. - POST /api/inference/install-latest-transformers provisions a new persistent .venv_t5_latest sidecar after user consent, pinned to the exact PyPI version (re-verified server-side) with the same --target/--no-deps recipe as the fixed sidecars. A JSON pin marker inside the dir records the installed package set, so restarts revalidate it and routing resolves the new highest-ranked tier automatically. A dependency preflight (compat_plan) compares the release's requires_dist against the running env: unsatisfied tokenizers/safetensors floors are shadow-installed as exact pins into the sidecar, anything else unsatisfied blocks the install with a clear message. Routing for every already-supported model_type is unchanged: the hardcoded lists and the 530/550/510 static resolver run first, the new tier only participates once its venv exists, and the probe order gains the latest sidecar only when provisioned. Verified against live PyPI and GitHub (transformers 5.13.0: 674 model_types, 26 absent from all installed overlays, e.g. cosmos3_omni; 4 dev-only on main) and with a real sidecar install plus restart persistence. 64 new tests; the existing 200-test transformers_version suite passes unchanged. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Latest-transformers check: fetch outside the lock, serialize installs Release the module lock during the network refresh so a slow fetch cannot stall other threads in the ASGI pool; concurrent callers during a fetch get None (the graceful fallthrough) via an in-flight flag instead of stacking fetches. Serialize install_latest_transformers with an in-progress flag so concurrent consents cannot race the sidecar delete and recreate; the loser gets a structured already-in-progress refusal. * Latest-transformers check: LoRA bases, pin-gated mapping, live reverify Run the upgrade check over the [adapter, base] target set so a LoRA whose base model is a brand-new architecture surfaces the prompt (the worker activates transformers for the base, not the adapter). Gate the latest overlay's mapping lookup on a valid pin marker, matching activation and the probe order, so a partial or manual .venv_t5_latest dir cannot be routed to and then refused at activation. Re-verify the requested version against a live PyPI snapshot at install time, falling back to the cached one on fetch failure, so a release published inside the cache TTL is not silently missed. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Latest-transformers check: nested config types and latest-tier vision probe Collect every model_type in the config (top level plus each nested sub-config) and signal on the first one missing from all installed overlays, so a supported wrapper carrying a brand-new backbone still surfaces the upgrade prompt; wrappers instantiate sub-configs through CONFIG_MAPPING and would fail on the nested type. Route the vision capability subprocess through the pinned latest sidecar when the model resolves to the latest tier, so latest-only VLMs are not misclassified as text-only; every other tier keeps the 5.5 sidecar used today. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Latest tier: nested routing, vision probe after raw miss, safe upgrades Route by every model_type in the config: a nested sub-config type can raise the tier (wrappers instantiate sub-configs through CONFIG_MAPPING), so a supported wrapper with a latest-only backbone routes to latest once installed instead of staying on default. An unknown nested type never vetoes; the primary type keeps its previous semantics. The collector is shared with the upgrade checker. Vision detection: when the raw heuristics say False for a model that routes to the latest tier, run the AutoConfig subprocess under the pinned latest sidecar instead of trusting heuristics built from older transformers. Provisioning: stage-and-swap. Build the new sidecar in .venv_t5_latest.staging and swap it in only when the install and pin marker are complete, so a failed upgrade never destroys a previously working sidecar; restore the old dir if the final swap fails. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Latest-transformers checker, vision subprocess, and cache fixes Require the latest release to support every missing model_type (the primary included) before prompting; a nested-only match cannot make the model loadable, so no install is offered for it. The vision-check subprocess now unions the active sidecar's own registry mappings into the inlined parent-process detection sets, so architectures only the sidecar knows classify correctly. A successful sidecar install clears the tier probe cache, the latest tier's model_type mapping, and the vision-detection cache so the new venv takes effect without a restart. Tests for all three. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Aggregate upgrade support flags and keep install off /v1 The upgrade signal now reports supported_in_pypi only when the latest release covers every missing model_type; a mix with a main-only nested type surfaces as dev-only so no PyPI install is offered that would still fail at load. The consented install endpoint moves to studio_router so it is not reachable through the OpenAI-compatible /v1 mount. Tests for both. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Honor the latest-transformers kill switch in routing With UNSLOTH_STUDIO_NO_LATEST_TRANSFORMERS set after the sidecar was provisioned, the latest tier still joined mapping and probe routing because only the pin was checked. Both admission points now also check the kill switch, so operators can roll back a problematic sidecar without deleting files. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Repair the latest sidecar through stage-and-swap The lazy repair path installed into the live .venv_t5_latest, which _ensure_venv_dir wipes first, so a failed repair deleted the pinned sidecar and its marker. Both the consented install and the repair now share one stage-and-swap helper: the incomplete-but-pinned dir survives any failure and a later attempt can still repair it. * Tighten comments * Remove the staging dir when a latest-sidecar install fails A pip failure inside _ensure_venv_dir returns False without raising, so the except cleanup never ran and the partial .venv_t5_latest.staging leaked until a later attempt. Also note on the validate response fields that frontend consumption ships in the follow-up PR. * Add the transformers-upgrade consent dialog to the frontend When /validate reports requires_transformers_upgrade, every explicit load path (chat runtime and the compare composer) now pauses on a consent dialog modeled on the remote-code one: it names the model_type and the latest PyPI transformers version, and on Accept calls /api/inference/install-latest-transformers itself, shows an installing state, and resumes the original load automatically on success. Errors surface in the dialog with a retry; Cancel aborts the load like the trust dialog's deny path. Architectures shipped only on transformers main get a dev-only notice with no install button. Background auto-load skips upgrade-requiring candidates instead of prompting, mirroring the trust_remote_code rule. The dialog mounts once in the root layout and runs before the security dialogs, since no load can proceed without the runtime. * Route a non-installable new architecture to the custom-code consent as a last resort When the upgrade dialog has no installable PyPI release (the architecture is only on transformers main, which Studio never installs), the dialog now says so explicitly, and when the model also declares custom (auto_map) code it offers Continue with custom code: resolving the paused load into the existing trust_remote_code consent gate instead of hard-aborting. Models with no custom code keep the Cancel-only notice. The backend returns no upgrade signal at all for architectures unknown to both PyPI and main, so those still route straight to the unchanged security gate. * Force a 16-bit load for models on the latest-transformers sidecar Live validation with Zyphra/ZAYA1-8B (model_type zaya, shipped by transformers 5.13.1 but unknown to every installed tier) surfaced a generation crash when the consented sidecar load kept the default bnb 4-bit quantization: transformers' grouped-MoE kernels feed the packed uint8 expert weights straight into torch._grouped_mm, and generation dies (plain 16-bit works). New latest_tier_active_for() mirrors the sidecar activation's tier resolution and never raises; the inference worker flips load_in_4bit off when it reports true, and the load route applies the same flip so the pre-load VRAM guard and the worker command agree. Fixed tiers are untouched. With the guard, ZAYA1-8B loads and generates correctly in Studio chat. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Offer the custom-code fallback when a latest-sidecar install fails * Fail remote mapping fetches wholesale and mirror the 16-bit flip in validate A transient fetch or parse failure of one auto-mapping file no longer caches a partial latest-release map for the TTL (a real 404 on pre-5.10 tags is still tolerated), and validate_model now applies the same latest-sidecar 16-bit sizing flip as /load before the training guard so the two agree. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Tighten comments in the latest-transformers changes * Resolve remote LoRA bases, fold nested tiers, and guard the sidecar swap latest_tier_active_for now resolves a remote adapter's base model the same way worker pre-activation does (and returns early without a sidecar pin), a hardcoded fast-path tier is raised when a nested sub-config's model_type needs a higher sidecar, and the install route refuses to swap .venv_t5_latest while training runs on it and unloads a latest-tier chat model first. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Gate the sidecar install on worker liveness and size installable upgrades 16-bit The install route now refuses while any training or export runs (tier re-resolution without the load token is unreliable for gated repos), holds the inference lifecycle gate across the unload and the swap so no load can interleave, and passes the model name to unload_model. validate_model runs the upgrade check before the training guard and sizes an installable upgrade as 16-bit, matching what /load and the worker will force after the consented install. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Close the sidecar install races and honor the kill switch over cached mappings Training starts and mutating export routes now refuse while a transformers install is in progress (shared is_install_in_progress flag), the chat unload and idle export-worker teardown moved into a before_swap hook that runs only once the staged install succeeded, and _config_model_types checks the kill switch before returning a cached latest mapping. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Reserve the sidecar swap before the gate wait and abort it on failed teardown The install-in-progress flag moved into a shared sidecar swap reservation in transformers_version, taken by the install route before awaiting the inference lifecycle gate (so training and export starts see it for the whole window) and by the lazy .venv_t5_latest repair path. The before_swap hook now raises when the chat unload or export teardown reports failure, leaving the previous sidecar untouched. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Back the sidecar swap reservation with a cross-process lock file The lazy repair runs inside worker subprocesses, where a module-level flag is invisible to the parent's route checks. The reservation now also creates a lock file next to .venv_t5_latest (O_EXCL, owner-only removal, stale after two hours for crashed owners), so is_install_in_progress sees a repair from any Studio process. * Hand the swap reservation to the installer thread and harden pre-swap teardown A cancelled install request no longer releases the reservation while the installer thread is still staging (the thread owns and releases it, shielded from cancellation). The route refuses while another inference request is generating, export teardown runs before the chat unload and is judged by worker liveness rather than the cleanup return value, and a live inference worker with no active model (failed load residue) is shut down before the swap. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Keep the lifecycle gate with the installer and recheck the swap at spawn time The gate moved into the shielded install task so a cancelled POST cannot release the guard /load honors while the installer still runs, cached latest probe results are ignored while the kill switch is set, and the training and export subprocess spawns recheck the sidecar swap reservation right before spawning (the route-level guards are one-shot and validation can outlast an install's start). * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Close the spawn-registration windows against the sidecar install Training marks the spawn in progress before its reservation recheck and is_training_active honors the flag, so the install route sees a start that has passed proc.start() but not yet recorded _proc. Export load-checkpoint rechecks the reservation after setting _export_active and before tearing down the old worker, so losing the race keeps the loaded checkpoint instead of surfacing a 500. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Refine the install-window interleavings around worker teardown The inference busy count is rechecked under the lifecycle gate (streams start by taking that gate, so nothing slips past a held gate), the training handshake moved ahead of the VRAM-freeing before_spawn hook so a lost race leaves chat/export intact, the export spawn-time check is op-aware (inside an active op the install is the side that aborts), and the Xet-stall respawn waits out a transient reservation instead of stranding the run. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Track the install's server-side unload and guard export ops against the swap The upgrade dialog store records when its install actually ran (the server unloads the active chat model before swapping), and the load flow then marks the previous model as unloaded so a later cancelled gate still triggers rollback; the custom-code fallback leaves the flag unset. _run_export gained the same reservation handshake as load_checkpoint so an install cannot block behind an hours-long export op instead of returning 409. * Tighten comments in the install-guard and upgrade-consent changes * Surface install-race refusals cleanly and roll back after a failed swap unload /load refuses while the sidecar swap is reserved so a load cannot succeed and immediately be unloaded by the pre-swap teardown, worker starts that lose the install race raise a typed SidecarSwapInProgress mapped to 409 instead of a 500, the install response reports model_unloaded even on a structured failure so the client can restore its state, and the compare flow tracks the server-side unload like the primary load path and clears a stale checkpoint on abort. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Type the export install races, scope the lock release, and keep the unload signal Export load-checkpoint and export ops raise SidecarSwapInProgress (mapped to 409 in every export route) instead of a 400-shaped failure, the export spawn check distinguishes repair reservations (always refused) from install ones (op-aware), the swap lock release only unlinks a lock this process wrote so a stale-superseded owner cannot drop the new owner's live lock, and the frontend unload signal survives a superseding consent via read-and-clear consumption instead of a reset. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Finalize a stalled run when the respawn loses the install race and latch the unload signal The Xet-stall respawn timeout now finalizes the run as a failure instead of raising into the pump's broad finalization catch (which stranded it in a training state with no worker), and a successful install retry ORs the model_unloaded signal with the latched value so a failed-after-unload first attempt still triggers rollback. * Recheck the swap under the load gate and latch the unload before resolver checks /load rechecks the sidecar reservation after acquiring the lifecycle gate (an install can reserve while the load queues on it), and the dialog store latches model_unloaded as soon as the install response arrives, before any resolver-identity guard, so a superseded consent's unload still reaches whichever load consumes the signal next. * Report cleared-state unload failures, guard queued installs, and fold name tiers A failed chat unload that still cleared the orchestrator's model state now reports model_unloaded so the client rolls back, the installer aborts with a 409 when a model load completed while it waited on the lifecycle gate, and the fixed-tier name fast path consults the config mapping when a latest sidecar is pinned so an accepted upgrade routes to the sidecar it installed (no I/O added to the unpinned path). * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Report cleared-state unload failures and harden the spawn handshake flag The failed-unload branch in before_swap now detects that the orchestrator cleared its model state and reports model_unloaded before aborting (the earlier commit claimed this fix but a scripting error dropped the edit), the installer's queued-load check compares a load generation counter so a same-model reload is caught, and both training spawn sites wrap everything after the handshake in a guard that resets _spawn_in_progress on any exception so a failed start cannot wedge is_training_active. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Bump the load generation when the load is published, not at load start A start-time bump is already visible when the installer snapshots mid-load, so a same-model reload completing after the snapshot looked unchanged and could be unloaded by the swap. The counter now increments alongside the active_model_name publish. * Self-heal a broken pinned sidecar, guard lazy repairs, and refresh stale retries A valid pin whose transformers source dir vanished now triggers the repair from the routing path (with a five minute backoff after failures) instead of silently routing latest-only models to older tiers, the lazy repair refuses while parent-visible chat/training/export workers are active since it has no teardown of its own, and a version-mismatch install failure carries the superseding release so the dialog's Retry re-requests a version that can succeed. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Flip latest-tier loads to 16-bit outside chat and protect export state Training and export workers now apply the same latest-sidecar 16-bit flip as the chat worker so a brand-new grouped-MoE architecture cannot reach bnb 4-bit through those paths, the latest-tier vision override returns None on an inconclusive probe so a transient failure is not cached as not-vision, and the install route refuses while an idle export checkpoint is loaded rather than discard it with no rollback signal on a failed swap. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Address parallel-review findings on the sidecar guards and install checks The training route sizes latest-tier jobs 16-bit before GPU selection, the inference subprocess spawn rechecks the swap reservation like training and export (covering the OpenAI auto-switch path) with the typed error mapped to a retryable 409, compat_plan blocks the install when dependency metadata cannot be fetched instead of proceeding unverified, snapshot model-type lists must contain only strings, and pin-marker package specs are validated against the sidecar's own package set before ever reaching pip. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Parent-only repairs, live-owner locks, remote-base activation, pre-teardown recheck Lazy sidecar repairs now refuse inside worker children (whose empty backend singletons cannot see live siblings) and run only in the parent where the active-worker guard is real, swap-lock staleness requires the owner pid to be dead so a slow live install is never superseded, both activation entry points resolve a remote adapter's base model like the inference worker and latest_tier_active_for already do, and load_model rechecks the reservation before tearing down the old worker so losing the race keeps the current model loaded. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Check workers under the repair reservation and keep state on refused swaps The lazy repair now reserves first and checks workers under the reservation (worker starts set their active markers before rechecking, so every interleaving aborts one side), with export ops and in-flight inference loads counted as active. The inference pre-teardown and spawn guards refuse only repair reservations since an install shares the load's lifecycle gate and aborts via its queued-load snapshot, a SidecarSwapInProgress raised before teardown no longer clears the live model mirrors, and an export spawn abort after teardown clears current_checkpoint so the page cannot claim a loaded checkpoint with no worker. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Repair a present-but-incomplete latest sidecar from routing The routing self-heal only fired when the pinned sidecar's transformers/ dir was missing. A sidecar that kept transformers/ but lost another pinned package still routed models to the latest tier, and workers refuse parent-only repairs, so every load failed until a manual reinstall. Routing now validates the full pin (via _venv_dir_is_valid) and repairs any incomplete sidecar under the same swap reservation and 5-minute backoff. * Treat an unrepaired latest sidecar as unavailable in routing When the pinned sidecar is incomplete and the lazy repair fails (offline, pip failure, workers active) or is inside the backoff window, routing returned the source dir anyway, sending models to a tier whose worker activation is known to fail. Return None instead so models an older tier supports keep loading there until a repair succeeds, matching the behavior when the sidecar dir is missing entirely. * Harden sidecar swap and repair against crash, survivor, and 16-bit paths Reclaim a swap lock as soon as its recorded owner PID is dead instead of waiting out the two-hour cutoff, so a crash mid-install no longer wedges /load, training, export, and repair for hours. A lock whose PID cannot be read yet still uses the long cutoff so the create-before-write window is never mistaken for dead. Probe process liveness with OpenProcess on Windows: os.kill(pid, 0) there is CTRL_C_EVENT (a real Ctrl+C via GenerateConsoleCtrlEvent), not a harmless check, and psutil is not always present. Return whether _shutdown_subprocess actually killed the worker and keep the live handle when it survives terminate/kill (an uninterruptible CUDA syscall can outlive SIGKILL). The pre-swap liveness guard now trusts that result, so the destructive .venv_t5_latest rename cannot proceed while a live worker still holds sidecar modules. Recover a sidecar stranded at .old when a swap's activation rename and its rollback both fail: reading the pin restores it when no swap holds the reservation, so latest-tier models are not permanently broken. Resolve the latest tier in the parent for export loads and for explicitly 16-bit training runs, not only 4-bit ones: tier resolution self-heals an incomplete sidecar, and repairs are parent-only, so those paths could not recover before. Sidecar integrity and quantization are independent. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Revert the parent-side latest-tier repair probe on training and export loads The probe ran before the route freed VRAM, so a resident chat or export worker made _workers_active_for_repair() refuse the parent-only repair; the route then tore that worker down and spawned a child that also cannot repair, so an incomplete sidecar still failed to load. Repairing correctly requires running the repair between the worker teardown and the child spawn, decoupled from VRAM sizing, which is a larger change tracked separately. Restore the prior behavior so these paths match the reviewed form and do not partially attempt a repair that cannot complete while workers are resident. * Honor failed worker shutdowns on load and revalidate the cached latest mapping The fresh-load paths spawned a new worker straight after _shutdown_subprocess without checking its result, so a worker that outlived terminate/kill (a wedged CUDA syscall) had its handle overwritten by the replacement while it still held GPU memory, and is_worker_alive/the pre-swap guard could no longer see it. Both the inference load and the export checkpoint load now abort when the old worker did not exit, so the load can be retried once it does. _config_model_types returned a cached latest mapping without re-checking the sidecar, so a sidecar deleted or broken in-process after its first parse was never re-validated: routing kept sending latest-only models to the stale latest tier while activation failed. The cached latest mapping is now dropped and re-resolved (self-healing) when the sidecar is no longer intact. * Drop cached latest mapping when the pin is gone; keep 4-bit for custom-code fallback _latest_sidecar_intact now returns False when the pin marker itself is gone, not just when a pinned package is missing. Otherwise a cached latest mapping outlived a deleted pin: _config_model_types kept returning it, so routing sent latest-only models to a tier whose worker activation then failed (no pinned version) until restart. It now drops the cache and re-resolves to no latest tier. The _overlay_transformers_dir caller already gates on a present pin, so it is unaffected. validate_model forced 16-bit sizing whenever a PyPI upgrade was merely offered, even for a model that can fall back to its own auto_map code. /load loads such a model 4-bit without the install, and the install route refuses while training is active, so 16-bit sizing here returned a VRAM 409 for the only viable 4-bit path. The offered-upgrade flip is now gated on the absence of a custom-code fallback; an already-active latest sidecar still always sizes 16-bit. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
612 lines
23 KiB
Python
612 lines
23 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
"""Export API routes: checkpoint discovery and model export operations."""
|
|
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any, AsyncGenerator, Dict, List, Optional, Tuple
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|
from fastapi.responses import StreamingResponse
|
|
import structlog
|
|
from loggers import get_logger
|
|
|
|
backend_path = Path(__file__).parent.parent.parent
|
|
if str(backend_path) not in sys.path:
|
|
sys.path.insert(0, str(backend_path))
|
|
|
|
from auth.authentication import get_current_subject
|
|
|
|
from utils.utils import safe_error_detail
|
|
|
|
try:
|
|
from core.export import get_export_backend
|
|
except ImportError:
|
|
parent_backend = backend_path.parent / "backend"
|
|
if str(parent_backend) not in sys.path:
|
|
sys.path.insert(0, str(parent_backend))
|
|
from core.export import get_export_backend
|
|
|
|
from models import (
|
|
LoadCheckpointRequest,
|
|
ExportStatusResponse,
|
|
ExportOperationResponse,
|
|
ExportMergedModelRequest,
|
|
ExportBaseModelRequest,
|
|
ExportGGUFRequest,
|
|
ExportLoRAAdapterRequest,
|
|
)
|
|
|
|
router = APIRouter()
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
def _ensure_export_supported() -> None:
|
|
"""Reject a mutating export request up front (HTTP 400) when the host can't export.
|
|
|
|
Keeps the backend authoritative even if a client bypasses the UI gate. Read-only endpoints
|
|
(scan/status/logs) are intentionally NOT gated so the Export page can still render the reason.
|
|
Also refuses (409) while a latest-transformers install is swapping .venv_t5_latest: an
|
|
export worker spawned mid-swap could activate a half-replaced sidecar.
|
|
"""
|
|
from utils.transformers_latest import is_install_in_progress
|
|
|
|
if is_install_in_progress():
|
|
raise HTTPException(
|
|
status_code = 409,
|
|
detail = "A transformers installation is in progress. Retry when it completes.",
|
|
)
|
|
|
|
from utils.hardware import export_capability
|
|
|
|
cap = export_capability()
|
|
if not cap.get("export_supported", True):
|
|
raise HTTPException(
|
|
status_code = 400,
|
|
detail = cap.get("export_unsupported_message")
|
|
or "Export is not supported on this platform.",
|
|
)
|
|
|
|
|
|
@router.post("/load-checkpoint", response_model = ExportOperationResponse)
|
|
async def load_checkpoint(
|
|
request: LoadCheckpointRequest, current_subject: str = Depends(get_current_subject)
|
|
):
|
|
"""Load a checkpoint into the export backend (ExportBackend.load_checkpoint).
|
|
|
|
Export runs in its own subprocess and is allowed to run in parallel with
|
|
training and inference. We deliberately do NOT stop training or unload the
|
|
chat model here -- if the GPU runs out of memory the load/export fails with
|
|
a clear error instead of tearing down the user's other running workloads.
|
|
"""
|
|
try:
|
|
_ensure_export_supported()
|
|
backend = get_export_backend()
|
|
# Run in a worker thread (spawns and waits on a subprocess, can take
|
|
# minutes) so the event loop stays free to serve the live log SSE stream.
|
|
success, message = await asyncio.to_thread(
|
|
backend.load_checkpoint,
|
|
checkpoint_path = request.checkpoint_path,
|
|
max_seq_length = request.max_seq_length,
|
|
load_in_4bit = request.load_in_4bit,
|
|
trust_remote_code = request.trust_remote_code,
|
|
approved_remote_code_fingerprint = request.approved_remote_code_fingerprint,
|
|
hf_token = request.hf_token,
|
|
subject = current_subject,
|
|
)
|
|
|
|
if not success:
|
|
raise HTTPException(status_code = 400, detail = message)
|
|
|
|
return ExportOperationResponse(success = True, message = message)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
from utils.transformers_version import SidecarSwapInProgress
|
|
|
|
if isinstance(e, SidecarSwapInProgress):
|
|
# Expected loss of the race against a sidecar install: retryable 409.
|
|
raise HTTPException(status_code = 409, detail = str(e))
|
|
logger.error(f"Error loading checkpoint: {e}", exc_info = True)
|
|
raise HTTPException(
|
|
status_code = 500,
|
|
detail = "Failed to 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 (ExportBackend.cleanup_memory)."""
|
|
try:
|
|
backend = get_export_backend()
|
|
success = await asyncio.to_thread(backend.cleanup_memory)
|
|
|
|
if not success:
|
|
raise HTTPException(
|
|
status_code = 500,
|
|
detail = "Memory cleanup failed. See server logs for details.",
|
|
)
|
|
|
|
return ExportOperationResponse(
|
|
success = True,
|
|
message = "Memory cleanup completed successfully",
|
|
)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error during export memory cleanup: {e}", exc_info = True)
|
|
raise HTTPException(
|
|
status_code = 500,
|
|
detail = "Failed to cleanup export memory",
|
|
)
|
|
|
|
|
|
@router.post("/cancel", response_model = ExportOperationResponse)
|
|
async def cancel_export(current_subject: str = Depends(get_current_subject)):
|
|
"""Cancel the in-flight export by terminating its worker subprocess.
|
|
|
|
Only the export subprocess is killed; training and inference run in their
|
|
own subprocesses and keep going.
|
|
"""
|
|
try:
|
|
backend = get_export_backend()
|
|
cancelled = await asyncio.to_thread(backend.cancel_export)
|
|
return ExportOperationResponse(
|
|
success = True,
|
|
message = "Export cancelled" if cancelled else "No active export to cancel",
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Error cancelling export: {e}", exc_info = True)
|
|
raise HTTPException(
|
|
status_code = 500,
|
|
detail = "Failed to cancel export",
|
|
)
|
|
|
|
|
|
@router.get("/status", response_model = ExportStatusResponse)
|
|
async def get_export_status(current_subject: str = Depends(get_current_subject)):
|
|
"""Get export backend status (loaded checkpoint, model type, PEFT flag)."""
|
|
try:
|
|
backend = get_export_backend()
|
|
last_op = backend.get_last_op()
|
|
# Relativise the recovered output path the same way the per-op POST response
|
|
# does, so the success banner shows an identical path on either route.
|
|
last_op_output_path = None
|
|
if last_op and last_op.get("output_path"):
|
|
details = _export_details(last_op["output_path"])
|
|
last_op_output_path = (details or {}).get("output_path")
|
|
return ExportStatusResponse(
|
|
current_checkpoint = backend.current_checkpoint,
|
|
is_vision = bool(getattr(backend, "is_vision", False)),
|
|
is_peft = bool(getattr(backend, "is_peft", False)),
|
|
is_export_active = bool(backend.is_export_active()),
|
|
active_op_kind = backend.get_active_op_kind(),
|
|
last_op_seq = int(last_op["seq"]) if last_op else 0,
|
|
last_op_kind = last_op.get("kind") if last_op else None,
|
|
last_op_status = last_op.get("status") if last_op else None,
|
|
last_op_output_path = last_op_output_path,
|
|
last_op_error = last_op.get("error") if last_op else None,
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Error getting export status: {e}", exc_info = True)
|
|
raise HTTPException(
|
|
status_code = 500,
|
|
detail = "Failed to get export status",
|
|
)
|
|
|
|
|
|
@router.get("/logs")
|
|
async def get_export_logs(
|
|
since: Optional[int] = Query(
|
|
None,
|
|
description = "Return log entries with seq strictly greater than this cursor.",
|
|
),
|
|
current_subject: str = Depends(get_current_subject),
|
|
):
|
|
"""Tunnel-safe JSON fallback for the live export log stream.
|
|
|
|
The SSE endpoint (`/logs/stream`) is the low-latency path, but some reverse
|
|
proxies -- notably Cloudflare quick tunnels (`*.trycloudflare.com`) used by
|
|
`--secure` mode -- buffer `text/event-stream` responses and only flush when
|
|
the stream closes, so over the tunnel the browser sees nothing for the whole
|
|
export ("connecting..." with no logs). This endpoint returns the same
|
|
ring-buffer lines as a short, complete JSON response that no proxy buffers,
|
|
so the frontend can poll it and still show logs in near real time.
|
|
|
|
Shares the orchestrator's monotonic `seq` cursor with the SSE stream, so the
|
|
two transports can run together and the client de-dupes by seq.
|
|
"""
|
|
try:
|
|
backend = get_export_backend()
|
|
# No cursor on the first poll of a run: start from the run-start snapshot
|
|
# so the client gets every line since the run began (matches the SSE
|
|
# default), not the entire historical ring buffer.
|
|
if since is None:
|
|
cursor = backend.get_run_start_seq()
|
|
else:
|
|
cursor = max(0, int(since))
|
|
|
|
entries, new_cursor = backend.get_logs_since(cursor)
|
|
return {
|
|
"entries": [
|
|
{
|
|
"seq": int(entry.get("seq", 0)),
|
|
"stream": entry.get("stream", "stdout"),
|
|
"line": entry.get("line", ""),
|
|
"ts": entry.get("ts"),
|
|
}
|
|
for entry in entries
|
|
],
|
|
"cursor": new_cursor,
|
|
"active": bool(backend.is_export_active()),
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"Error getting export logs: {e}", exc_info = True)
|
|
raise HTTPException(
|
|
status_code = 500,
|
|
detail = "Failed to get export logs",
|
|
)
|
|
|
|
|
|
def _try_register_external_export(path: Path) -> tuple[bool, Optional[str]]:
|
|
"""Best-effort registration so absolute exports show up in local scans."""
|
|
try:
|
|
from storage.studio_db import add_scan_folder
|
|
folder = add_scan_folder(str(path))
|
|
return True, str(folder.get("path") or path)
|
|
except Exception as exc:
|
|
logger.warning("Could not register export scan folder %s: %s", path, exc)
|
|
return False, None
|
|
|
|
|
|
def _export_details(output_path: Optional[str]) -> Optional[Dict[str, Any]]:
|
|
"""Return relative export paths, keeping external absolute paths visible."""
|
|
if not output_path:
|
|
return None
|
|
try:
|
|
from utils.paths.storage_roots import exports_root
|
|
|
|
path = Path(output_path)
|
|
# If it's outside exports_root, return the full absolute path
|
|
# so users can find their files on a different drive.
|
|
if path.is_absolute():
|
|
try:
|
|
path.resolve().relative_to(exports_root().resolve())
|
|
except ValueError:
|
|
registered, registered_path = _try_register_external_export(path)
|
|
return {
|
|
"output_path": str(path),
|
|
"scan_folder_registered": registered,
|
|
"scan_folder_path": registered_path,
|
|
}
|
|
rel = os.path.relpath(output_path, exports_root())
|
|
return {"output_path": rel}
|
|
except Exception:
|
|
return {"output_path": output_path}
|
|
|
|
|
|
@router.post("/export/merged", response_model = ExportOperationResponse)
|
|
async def export_merged_model(
|
|
request: ExportMergedModelRequest, current_subject: str = Depends(get_current_subject)
|
|
):
|
|
"""Export a merged PEFT model (16-bit or 4-bit), optionally pushing to Hub.
|
|
|
|
Wraps ExportBackend.export_merged_model.
|
|
"""
|
|
try:
|
|
_ensure_export_supported()
|
|
backend = get_export_backend()
|
|
success, message, output_path = await asyncio.to_thread(
|
|
backend.export_merged_model,
|
|
save_directory = request.save_directory,
|
|
format_type = request.format_type,
|
|
push_to_hub = request.push_to_hub,
|
|
repo_id = request.repo_id,
|
|
hf_token = request.hf_token,
|
|
private = request.private,
|
|
compressed_method = request.compressed_method,
|
|
)
|
|
|
|
if not success:
|
|
raise HTTPException(status_code = 400, detail = message)
|
|
|
|
return ExportOperationResponse(
|
|
success = True,
|
|
message = message,
|
|
details = _export_details(output_path),
|
|
)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
from utils.transformers_version import SidecarSwapInProgress
|
|
|
|
if isinstance(e, SidecarSwapInProgress):
|
|
# Expected loss of the race against a sidecar install: retryable 409.
|
|
raise HTTPException(status_code = 409, detail = str(e))
|
|
logger.error(f"Error exporting merged model: {e}", exc_info = True)
|
|
raise HTTPException(
|
|
status_code = 500,
|
|
detail = "Failed to export merged model",
|
|
)
|
|
|
|
|
|
@router.post("/export/base", response_model = ExportOperationResponse)
|
|
async def export_base_model(
|
|
request: ExportBaseModelRequest, current_subject: str = Depends(get_current_subject)
|
|
):
|
|
"""Export a non-PEFT base model, optionally pushing to Hub.
|
|
|
|
Wraps ExportBackend.export_base_model.
|
|
"""
|
|
try:
|
|
_ensure_export_supported()
|
|
backend = get_export_backend()
|
|
success, message, output_path = await asyncio.to_thread(
|
|
backend.export_base_model,
|
|
save_directory = request.save_directory,
|
|
push_to_hub = request.push_to_hub,
|
|
repo_id = request.repo_id,
|
|
hf_token = request.hf_token,
|
|
private = request.private,
|
|
base_model_id = request.base_model_id,
|
|
)
|
|
|
|
if not success:
|
|
raise HTTPException(status_code = 400, detail = message)
|
|
|
|
return ExportOperationResponse(
|
|
success = True,
|
|
message = message,
|
|
details = _export_details(output_path),
|
|
)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
from utils.transformers_version import SidecarSwapInProgress
|
|
|
|
if isinstance(e, SidecarSwapInProgress):
|
|
# Expected loss of the race against a sidecar install: retryable 409.
|
|
raise HTTPException(status_code = 409, detail = str(e))
|
|
logger.error(f"Error exporting base model: {e}", exc_info = True)
|
|
raise HTTPException(
|
|
status_code = 500,
|
|
detail = "Failed to export base model",
|
|
)
|
|
|
|
|
|
@router.post("/export/gguf", response_model = ExportOperationResponse)
|
|
async def export_gguf(
|
|
request: ExportGGUFRequest, current_subject: str = Depends(get_current_subject)
|
|
):
|
|
"""Export the current model to GGUF format, optionally pushing to Hub.
|
|
|
|
Wraps ExportBackend.export_gguf.
|
|
"""
|
|
try:
|
|
_ensure_export_supported()
|
|
backend = get_export_backend()
|
|
# A custom path wins; otherwise the imatrix toggle requests the upstream auto-download.
|
|
imatrix_file = request.imatrix_path or (True if request.imatrix else None)
|
|
success, message, output_path = await asyncio.to_thread(
|
|
backend.export_gguf,
|
|
save_directory = request.save_directory,
|
|
quantization_method = request.quantization_method,
|
|
push_to_hub = request.push_to_hub,
|
|
repo_id = request.repo_id,
|
|
hf_token = request.hf_token,
|
|
imatrix_file = imatrix_file,
|
|
)
|
|
|
|
if not success:
|
|
raise HTTPException(status_code = 400, detail = message)
|
|
|
|
return ExportOperationResponse(
|
|
success = True,
|
|
message = message,
|
|
details = _export_details(output_path),
|
|
)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
from utils.transformers_version import SidecarSwapInProgress
|
|
|
|
if isinstance(e, SidecarSwapInProgress):
|
|
# Expected loss of the race against a sidecar install: retryable 409.
|
|
raise HTTPException(status_code = 409, detail = str(e))
|
|
logger.error(f"Error exporting GGUF model: {e}", exc_info = True)
|
|
raise HTTPException(
|
|
status_code = 500,
|
|
detail = "Failed to export GGUF model",
|
|
)
|
|
|
|
|
|
@router.post("/export/lora", response_model = ExportOperationResponse)
|
|
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).
|
|
|
|
Wraps ExportBackend.export_lora_adapter.
|
|
"""
|
|
try:
|
|
_ensure_export_supported()
|
|
backend = get_export_backend()
|
|
success, message, output_path = await asyncio.to_thread(
|
|
backend.export_lora_adapter,
|
|
save_directory = request.save_directory,
|
|
push_to_hub = request.push_to_hub,
|
|
repo_id = request.repo_id,
|
|
hf_token = request.hf_token,
|
|
private = request.private,
|
|
gguf = request.gguf,
|
|
gguf_outtype = request.gguf_outtype,
|
|
)
|
|
|
|
if not success:
|
|
raise HTTPException(status_code = 400, detail = message)
|
|
|
|
return ExportOperationResponse(
|
|
success = True,
|
|
message = message,
|
|
details = _export_details(output_path),
|
|
)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
from utils.transformers_version import SidecarSwapInProgress
|
|
|
|
if isinstance(e, SidecarSwapInProgress):
|
|
# Expected loss of the race against a sidecar install: retryable 409.
|
|
raise HTTPException(status_code = 409, detail = str(e))
|
|
logger.error(f"Error exporting LoRA adapter: {e}", exc_info = True)
|
|
raise HTTPException(
|
|
status_code = 500,
|
|
detail = "Failed to export LoRA adapter",
|
|
)
|
|
|
|
|
|
# Live export log stream (Server-Sent Events).
|
|
#
|
|
# The export worker's stdout/stderr is piped to the orchestrator as log
|
|
# entries (core/export/worker.py, orchestrator.py); this endpoint streams
|
|
# them to the browser for a live terminal panel during export operations.
|
|
#
|
|
# Shape follows routes/training.py::stream_training_progress: each event
|
|
# carries id/event/data, the stream starts with a `retry:` directive, and
|
|
# `Last-Event-ID` is honored on reconnect.
|
|
|
|
|
|
def _format_sse(
|
|
data: str,
|
|
event: str,
|
|
event_id: Optional[int] = None,
|
|
) -> str:
|
|
"""Format a single SSE message with id/event/data fields."""
|
|
lines = []
|
|
if event_id is not None:
|
|
lines.append(f"id: {event_id}")
|
|
lines.append(f"event: {event}")
|
|
lines.append(f"data: {data}")
|
|
lines.append("")
|
|
lines.append("")
|
|
return "\n".join(lines)
|
|
|
|
|
|
@router.get("/logs/stream")
|
|
async def stream_export_logs(
|
|
request: Request,
|
|
since: Optional[int] = Query(
|
|
None,
|
|
description = "Return log entries with seq strictly greater than this cursor.",
|
|
),
|
|
current_subject: str = Depends(get_current_subject),
|
|
):
|
|
"""
|
|
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` : once the worker is idle and no new lines arrived for
|
|
~1 second. Clients should close.
|
|
- `error` : unrecoverable server-side error
|
|
|
|
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()
|
|
|
|
# Starting cursor: explicit `since` wins, then Last-Event-ID on reconnect,
|
|
# else the run-start snapshot so the client sees every line since the run
|
|
# began even if the SSE connection opened after the export-kickoff POST.
|
|
last_event_id = request.headers.get("last-event-id")
|
|
if since is None and last_event_id is not None:
|
|
try:
|
|
since = int(last_event_id)
|
|
except ValueError:
|
|
pass
|
|
|
|
if since is None:
|
|
cursor = backend.get_run_start_seq()
|
|
else:
|
|
cursor = max(0, int(since))
|
|
|
|
async def event_generator() -> AsyncGenerator[str, None]:
|
|
nonlocal cursor
|
|
# Reconnect after 3 seconds if the connection drops mid-export.
|
|
yield "retry: 3000\n\n"
|
|
|
|
last_yield = time.monotonic()
|
|
idle_since: Optional[float] = None
|
|
try:
|
|
while True:
|
|
if await request.is_disconnected():
|
|
return
|
|
|
|
entries, new_cursor = backend.get_logs_since(cursor)
|
|
if entries:
|
|
for entry in entries:
|
|
payload = json.dumps(
|
|
{
|
|
"stream": entry.get("stream", "stdout"),
|
|
"line": entry.get("line", ""),
|
|
"ts": entry.get("ts"),
|
|
}
|
|
)
|
|
yield _format_sse(
|
|
payload,
|
|
event = "log",
|
|
event_id = int(entry.get("seq", 0)),
|
|
)
|
|
cursor = new_cursor
|
|
last_yield = time.monotonic()
|
|
idle_since = None
|
|
else:
|
|
now = time.monotonic()
|
|
if now - last_yield > 10.0:
|
|
yield _format_sse("{}", event = "heartbeat")
|
|
last_yield = now
|
|
if not backend.is_export_active():
|
|
# Let the reader thread drain trailing lines printed just
|
|
# before the worker signalled done.
|
|
if idle_since is None:
|
|
idle_since = now
|
|
elif now - idle_since > 1.0:
|
|
yield _format_sse(
|
|
"{}",
|
|
event = "complete",
|
|
event_id = cursor,
|
|
)
|
|
return
|
|
else:
|
|
idle_since = None
|
|
|
|
await asyncio.sleep(0.1)
|
|
except asyncio.CancelledError:
|
|
# Client disconnected mid-yield: end cleanly so StreamingResponse finalizes.
|
|
return
|
|
except Exception as exc:
|
|
logger.error("Export log stream failed: %s", exc, exc_info = True)
|
|
try:
|
|
yield _format_sse(
|
|
json.dumps({"error": safe_error_detail(exc)}),
|
|
event = "error",
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
return StreamingResponse(
|
|
event_generator(),
|
|
media_type = "text/event-stream",
|
|
headers = {
|
|
"Cache-Control": "no-cache",
|
|
"Connection": "keep-alive",
|
|
"X-Accel-Buffering": "no",
|
|
},
|
|
)
|