Compare commits
4 commits
main
...
feature/st
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a9c8c0f72 | ||
|
|
23b1bb1de1 | ||
|
|
af575ef3c0 |
||
|
|
cf2909caed |
6 changed files with 174 additions and 37 deletions
|
|
@ -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 <token>
|
||||
security = HTTPBearer(auto_error = False) # Reads Authorization: Bearer <token>
|
||||
|
||||
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,22 @@ import {
|
|||
refreshSession,
|
||||
} from "@/features/auth";
|
||||
|
||||
type AuthStatus = {
|
||||
initialized: boolean;
|
||||
requires_password_change: boolean;
|
||||
auth_disabled?: boolean;
|
||||
};
|
||||
|
||||
async function getAuthStatus(): Promise<AuthStatus | null> {
|
||||
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<boolean> {
|
||||
if (hasAuthToken()) return true;
|
||||
if (!hasRefreshToken()) return false;
|
||||
|
|
@ -17,28 +33,22 @@ async function hasActiveSession(): Promise<boolean> {
|
|||
}
|
||||
|
||||
async function checkAuthInitialized(): Promise<boolean> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<void> {
|
|||
}
|
||||
|
||||
export async function requireGuest(): Promise<void> {
|
||||
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<void> {
|
||||
const status = await getAuthStatus();
|
||||
if (status?.auth_disabled) {
|
||||
throw redirect({ to: getPostAuthRoute() });
|
||||
}
|
||||
|
||||
const requiresPasswordChange = await checkPasswordChangeRequired();
|
||||
|
||||
if (requiresPasswordChange) return;
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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<typeof setTimeout> | 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<typeof setInterval> | null = null;
|
||||
let metricsTimer: ReturnType<typeof setInterval> | 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();
|
||||
};
|
||||
}, []);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue