diff --git a/studio/backend/auth/authentication.py b/studio/backend/auth/authentication.py index b39f915764..409dee29a9 100644 --- a/studio/backend/auth/authentication.py +++ b/studio/backend/auth/authentication.py @@ -1,6 +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 +import os import secrets from datetime import datetime, timedelta, timezone from typing import Optional, Tuple @@ -21,7 +22,28 @@ ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES = 60 REFRESH_TOKEN_EXPIRE_DAYS = 7 -security = HTTPBearer() # Reads Authorization: Bearer +security = HTTPBearer(auto_error = False) # Reads Authorization: Bearer + + +def is_auth_disabled() -> bool: + """ + Return True when auth should be bypassed. + + For the HF Spaces branch, auth is disabled by default so users can access + Studio without login/signup. You can explicitly re-enable auth with: + UNSLOTH_STUDIO_ENABLE_AUTH=1 + """ + raw_enable = os.getenv("UNSLOTH_STUDIO_ENABLE_AUTH", "").strip().lower() + if raw_enable in {"1", "true", "yes", "on"}: + return False + + raw = os.getenv("UNSLOTH_STUDIO_DISABLE_AUTH", "").strip().lower() + if raw in {"1", "true", "yes", "on"}: + return True + if raw in {"0", "false", "no", "off"}: + return False + + return True def _get_secret_for_subject(subject: str) -> str: @@ -103,7 +125,7 @@ def reload_secret() -> None: async def get_current_subject( - credentials: HTTPAuthorizationCredentials = Depends(security), + credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), ) -> str: """Validate JWT and require the password-change flow to be completed.""" return await _get_current_subject( @@ -113,7 +135,7 @@ async def get_current_subject( async def get_current_subject_allow_password_change( - credentials: HTTPAuthorizationCredentials = Depends(security), + credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), ) -> str: """Validate JWT but allow access to the password-change endpoint.""" return await _get_current_subject( @@ -123,7 +145,7 @@ async def get_current_subject_allow_password_change( async def _get_current_subject( - credentials: HTTPAuthorizationCredentials, + credentials: Optional[HTTPAuthorizationCredentials], *, allow_password_change: bool, ) -> str: @@ -136,6 +158,15 @@ async def _get_current_subject( async def secure_endpoint(current_subject: str = Depends(get_current_subject)): ... """ + if is_auth_disabled(): + return "hf-space-user" + + if credentials is None: + raise HTTPException( + status_code = status.HTTP_401_UNAUTHORIZED, + detail = "Missing bearer token", + ) + token = credentials.credentials subject = _decode_subject_without_verification(token) if subject is None: diff --git a/studio/backend/models/auth.py b/studio/backend/models/auth.py index 73d21130ae..edba6ac250 100644 --- a/studio/backend/models/auth.py +++ b/studio/backend/models/auth.py @@ -34,6 +34,10 @@ class AuthStatusResponse(BaseModel): ..., description = "True if the seeded admin must still change the default password", ) + auth_disabled: bool = Field( + default = False, + description = "True when auth is bypassed (HF Spaces mode).", + ) class ChangePasswordRequest(BaseModel): diff --git a/studio/backend/routes/auth.py b/studio/backend/routes/auth.py index db37ed837d..92b6df08c6 100644 --- a/studio/backend/routes/auth.py +++ b/studio/backend/routes/auth.py @@ -20,6 +20,7 @@ from auth.authentication import ( create_refresh_token, get_current_subject, get_current_subject_allow_password_change, + is_auth_disabled, refresh_access_token, ) @@ -34,6 +35,14 @@ async def auth_status() -> AuthStatusResponse: - initialized = False -> frontend should wait for the seeded admin bootstrap. - initialized = True -> frontend should show login or force the first password change. """ + if is_auth_disabled(): + return AuthStatusResponse( + initialized = True, + default_username = storage.DEFAULT_ADMIN_USERNAME, + requires_password_change = False, + auth_disabled = True, + ) + return AuthStatusResponse( initialized = storage.is_initialized(), default_username = storage.DEFAULT_ADMIN_USERNAME, @@ -42,6 +51,7 @@ async def auth_status() -> AuthStatusResponse: ) if storage.is_initialized() else True, + auth_disabled = False, ) diff --git a/studio/frontend/src/app/auth-guards.ts b/studio/frontend/src/app/auth-guards.ts index 1dcdfcb143..27453e2bfe 100644 --- a/studio/frontend/src/app/auth-guards.ts +++ b/studio/frontend/src/app/auth-guards.ts @@ -10,6 +10,22 @@ import { refreshSession, } from "@/features/auth"; +type AuthStatus = { + initialized: boolean; + requires_password_change: boolean; + auth_disabled?: boolean; +}; + +async function getAuthStatus(): Promise { + try { + const res = await fetch("/api/auth/status"); + if (!res.ok) return null; + return (await res.json()) as AuthStatus; + } catch { + return null; + } +} + async function hasActiveSession(): Promise { if (hasAuthToken()) return true; if (!hasRefreshToken()) return false; @@ -17,28 +33,22 @@ async function hasActiveSession(): Promise { } async function checkAuthInitialized(): Promise { - try { - const res = await fetch("/api/auth/status"); - if (!res.ok) return true; // fallback to login on error - const data = (await res.json()) as { initialized: boolean }; - return data.initialized; - } catch { - return true; // fallback to login on error - } + const status = await getAuthStatus(); + if (status?.auth_disabled) return true; + return status?.initialized ?? true; // fallback to login on error } async function checkPasswordChangeRequired(): Promise { - try { - const res = await fetch("/api/auth/status"); - if (!res.ok) return mustChangePassword(); - const data = (await res.json()) as { requires_password_change: boolean }; - return data.requires_password_change || mustChangePassword(); - } catch { - return mustChangePassword(); - } + const status = await getAuthStatus(); + if (status?.auth_disabled) return false; + if (!status) return mustChangePassword(); + return status.requires_password_change || mustChangePassword(); } export async function requireAuth(): Promise { + const status = await getAuthStatus(); + if (status?.auth_disabled) return; + if (await hasActiveSession()) { if (await checkPasswordChangeRequired()) { throw redirect({ to: "/change-password" }); @@ -52,11 +62,21 @@ export async function requireAuth(): Promise { } export async function requireGuest(): Promise { + const status = await getAuthStatus(); + if (status?.auth_disabled) { + throw redirect({ to: getPostAuthRoute() }); + } + if (!(await hasActiveSession())) return; throw redirect({ to: getPostAuthRoute() }); } export async function requirePasswordChangeFlow(): Promise { + const status = await getAuthStatus(); + if (status?.auth_disabled) { + throw redirect({ to: getPostAuthRoute() }); + } + const requiresPasswordChange = await checkPasswordChangeRequired(); if (requiresPasswordChange) return; diff --git a/studio/frontend/src/features/training/hooks/use-training-runtime-lifecycle.ts b/studio/frontend/src/features/training/hooks/use-training-runtime-lifecycle.ts index 5965e07eaa..f904238b08 100644 --- a/studio/frontend/src/features/training/hooks/use-training-runtime-lifecycle.ts +++ b/studio/frontend/src/features/training/hooks/use-training-runtime-lifecycle.ts @@ -15,6 +15,15 @@ import type { TrainingRuntimeStore } from "../types/runtime"; const STATUS_POLL_INTERVAL_MS = 3000; const METRICS_POLL_INTERVAL_MS = 5000; const STREAM_RECONNECT_DELAY_MS = 1500; +const AUTH_STATUS_RETRY_INTERVAL_MS = 3000; +const AUTH_STATUS_TIMEOUT_MS = 3000; +const INITIAL_HYDRATE_TIMEOUT_MS = 4000; + +function wait(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} function shouldUseLiveSync(state: TrainingRuntimeStore): boolean { return state.isTrainingRunning || state.phase === "training"; @@ -26,9 +35,46 @@ export function useTrainingRuntimeLifecycle(): void { let openingStream = false; let streamController: AbortController | null = null; let reconnectTimer: ReturnType | null = null; + /** HF Spaces / auth-disabled: no JWT, but train APIs allow anonymous access. */ + let authDisabled = false; const runtimeStore = useTrainingRuntimeStore; + const canUseTrainApi = () => hasAuthToken() || authDisabled; + let authProbeInFlight = false; + let lastAuthProbeStartedAt = 0; + + const maybeRefreshAuthMode = async (force = false) => { + if (disposed || hasAuthToken() || authDisabled) return; + if (authProbeInFlight) return; + + const now = Date.now(); + if ( + !force && + now - lastAuthProbeStartedAt < AUTH_STATUS_RETRY_INTERVAL_MS + ) { + return; + } + + authProbeInFlight = true; + lastAuthProbeStartedAt = now; + const controller = new AbortController(); + const timeout = setTimeout(() => { + controller.abort(); + }, AUTH_STATUS_TIMEOUT_MS); + try { + const res = await fetch("/api/auth/status", { signal: controller.signal }); + if (!res.ok) return; + const data = (await res.json()) as { auth_disabled?: boolean }; + authDisabled = Boolean(data.auth_disabled); + } catch { + // Keep previous mode and retry later. + } finally { + clearTimeout(timeout); + authProbeInFlight = false; + } + }; + const clearReconnect = () => { if (reconnectTimer) { clearTimeout(reconnectTimer); @@ -46,7 +92,10 @@ export function useTrainingRuntimeLifecycle(): void { }; const pollMetrics = async () => { - if (!hasAuthToken()) return; + if (!canUseTrainApi()) { + void maybeRefreshAuthMode(); + return; + } const gen = runtimeStore.getState().resetGeneration; try { const metrics = await getTrainingMetrics(); @@ -55,14 +104,17 @@ export function useTrainingRuntimeLifecycle(): void { } runtimeStore.getState().applyMetrics(metrics); } catch (error) { - if (!isAbortError(error) && !disposed && hasAuthToken()) { + if (!isAbortError(error) && !disposed && canUseTrainApi()) { runtimeStore.getState().setSseConnected(false); } } }; const pollStatus = async () => { - if (!hasAuthToken()) return; + if (!canUseTrainApi()) { + void maybeRefreshAuthMode(); + return; + } const gen = runtimeStore.getState().resetGeneration; try { const status = await getTrainingStatus(); @@ -79,7 +131,7 @@ export function useTrainingRuntimeLifecycle(): void { stopStream(); } } catch (error) { - if (!isAbortError(error) && !disposed && hasAuthToken()) { + if (!isAbortError(error) && !disposed && canUseTrainApi()) { runtimeStore.getState().setSseConnected(false); } } @@ -153,7 +205,11 @@ export function useTrainingRuntimeLifecycle(): void { const hydrate = async () => { runtimeStore.getState().setHydrating(true); try { - await Promise.all([pollStatus(), pollMetrics()]); + await maybeRefreshAuthMode(true); + await Promise.race([ + Promise.allSettled([pollStatus(), pollMetrics()]).then(() => undefined), + wait(INITIAL_HYDRATE_TIMEOUT_MS), + ]); } finally { if (!disposed) { runtimeStore.getState().setHydrating(false); @@ -162,13 +218,14 @@ export function useTrainingRuntimeLifecycle(): void { } }; - void hydrate(); + let statusTimer: ReturnType | null = null; + let metricsTimer: ReturnType | null = null; - const statusTimer = setInterval(() => { + void hydrate(); + statusTimer = setInterval(() => { void pollStatus(); }, STATUS_POLL_INTERVAL_MS); - - const metricsTimer = setInterval(() => { + metricsTimer = setInterval(() => { const state = runtimeStore.getState(); if (shouldUseLiveSync(state) || state.currentStep > 0) { void pollMetrics(); @@ -177,8 +234,8 @@ export function useTrainingRuntimeLifecycle(): void { return () => { disposed = true; - clearInterval(statusTimer); - clearInterval(metricsTimer); + if (statusTimer) clearInterval(statusTimer); + if (metricsTimer) clearInterval(metricsTimer); stopStream(); }; }, []); diff --git a/studio/setup.sh b/studio/setup.sh index 4c8a6c7dde..0e5121aa20 100755 --- a/studio/setup.sh +++ b/studio/setup.sh @@ -325,19 +325,34 @@ rm -rf "$LLAMA_CPP_DIR" # Detect GPU compute capability and limit CUDA architectures # Without this, cmake builds for ALL default archs (very slow) + # CUDA_ARCHS can be pre-set via env var (e.g. Docker ARG) to + # include targets not detectable at build time (no GPU access). + # We always include 86 (A10) and merge with detected GPUs. + _add_arch() { + local _a="$1" + case ";$CUDA_ARCHS;" in + *";$_a;"*) ;; + *) CUDA_ARCHS="${CUDA_ARCHS:+$CUDA_ARCHS;}$_a" ;; + esac + } CUDA_ARCHS="" + # Merge any pre-set CUDA_ARCHS from environment + if [ -n "${CUDA_ARCHS_EXTRA:-}" ]; then + IFS=';' read -ra _env_archs <<< "$CUDA_ARCHS_EXTRA" + for _ea in "${_env_archs[@]}"; do + _ea=$(echo "$_ea" | tr -d '[:space:]') + [ -n "$_ea" ] && _add_arch "$_ea" + done + fi + # Always include sm_86 (A10) + _add_arch "86" if command -v nvidia-smi &>/dev/null; then # Read all GPUs, deduplicate (handles mixed-GPU hosts) _raw_caps=$(nvidia-smi --query-gpu=compute_cap --format=csv,noheader 2>/dev/null || true) while IFS= read -r _cap; do _cap=$(echo "$_cap" | tr -d '[:space:]') if [[ "$_cap" =~ ^([0-9]+)\.([0-9]+)$ ]]; then - _arch="${BASH_REMATCH[1]}${BASH_REMATCH[2]}" - # Append if not already present - case ";$CUDA_ARCHS;" in - *";$_arch;"*) ;; - *) CUDA_ARCHS="${CUDA_ARCHS:+$CUDA_ARCHS;}$_arch" ;; - esac + _add_arch "${BASH_REMATCH[1]}${BASH_REMATCH[2]}" fi done <<< "$_raw_caps" fi