Studio: validate Hugging Face tokens before use (#7261)
* Studio: validate Hugging Face tokens before use * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: keep token validation failures non-blocking * Studio: harden Hugging Face token preflight * Studio: make token validation effect lint-safe --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
This commit is contained in:
parent
e092895e01
commit
1b3bce0530
20 changed files with 869 additions and 77 deletions
|
|
@ -5,8 +5,10 @@
|
|||
|
||||
from hub.routes.inventory import router as inventory_router
|
||||
from hub.routes.datasets import router as datasets_router
|
||||
from hub.routes.token import router as token_router
|
||||
|
||||
__all__ = [
|
||||
"inventory_router",
|
||||
"datasets_router",
|
||||
"token_router",
|
||||
]
|
||||
|
|
|
|||
44
studio/backend/hub/routes/token.py
Normal file
44
studio/backend/hub/routes/token.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Hugging Face token validation endpoint."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Literal, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
from auth.authentication import get_current_subject
|
||||
from hub.dependencies import get_hf_token
|
||||
from utils.client_ip import client_ip
|
||||
from utils.hf_token_validation import validate_hf_token
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class HfTokenValidationResponse(BaseModel):
|
||||
status: Literal["missing", "valid", "invalid", "rate_limited", "unavailable"]
|
||||
retry_after_seconds: Optional[int] = None
|
||||
|
||||
|
||||
@router.post("/token/validate", response_model = HfTokenValidationResponse)
|
||||
async def validate_token(
|
||||
request: Request,
|
||||
hf_token: Optional[str] = Depends(get_hf_token),
|
||||
current_subject: str = Depends(get_current_subject),
|
||||
):
|
||||
if not hf_token:
|
||||
return HfTokenValidationResponse(status = "missing")
|
||||
result = await asyncio.to_thread(
|
||||
validate_hf_token,
|
||||
hf_token,
|
||||
rate_key = f"{current_subject}:{client_ip(request)}",
|
||||
)
|
||||
return HfTokenValidationResponse(
|
||||
status = result.status,
|
||||
retry_after_seconds = result.retry_after_seconds,
|
||||
)
|
||||
|
|
@ -312,6 +312,7 @@ from routes.preview import router as preview_router
|
|||
from hub.routes import (
|
||||
inventory_router as hub_inventory_router,
|
||||
datasets_router as hub_datasets_router,
|
||||
token_router as hub_token_router,
|
||||
)
|
||||
from hub.schemas.downloads import TransportCapabilities
|
||||
from hub.utils.download_registry import (
|
||||
|
|
@ -993,6 +994,7 @@ app.include_router(rag_router, prefix = "/api/rag", tags = ["rag"])
|
|||
app.include_router(training_history_router, prefix = "/api/train", tags = ["training-history"])
|
||||
app.include_router(hub_inventory_router, prefix = "/api/hub", tags = ["hub"])
|
||||
app.include_router(hub_datasets_router, prefix = "/api/hub/datasets", tags = ["hub"])
|
||||
app.include_router(hub_token_router, prefix = "/api/hub", tags = ["hub"])
|
||||
|
||||
# Re-wrap client-error responses on the /v1/* surface into OpenAI/Anthropic
|
||||
# error envelopes; non-/v1 paths keep FastAPI's default {"detail": ...} shape.
|
||||
|
|
|
|||
|
|
@ -1778,7 +1778,7 @@ from core.inference.providers import get_base_url
|
|||
from core.inference.external_provider import ExternalProviderClient
|
||||
from core.inference.chat_templates import resolve_effective_chat_template_override
|
||||
from storage import providers_db
|
||||
from utils.utils import safe_error_detail, log_and_http_error
|
||||
from utils.utils import is_hf_authentication_error, safe_error_detail, log_and_http_error
|
||||
|
||||
import io
|
||||
import base64
|
||||
|
|
@ -5244,6 +5244,14 @@ async def validate_model(
|
|||
raise HTTPException(status_code = 400, detail = str(e))
|
||||
except Exception as e:
|
||||
redacted_msg = redact_native_paths(str(e))
|
||||
if is_hf_authentication_error(e):
|
||||
raise HTTPException(
|
||||
status_code = 400,
|
||||
detail = (
|
||||
"Hugging Face authentication failed. Check or clear the token "
|
||||
"in Settings, and confirm access to this gated repository."
|
||||
),
|
||||
)
|
||||
if _is_unsupported_nvfp4_inference_error(redacted_msg):
|
||||
logger.warning(
|
||||
"NVFP4 inference is not supported yet while validating '%s'",
|
||||
|
|
|
|||
165
studio/backend/tests/test_hf_token_validation.py
Normal file
165
studio/backend/tests/test_hf_token_validation.py
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Focused coverage for cached, rate-limited HF token validation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
|
||||
_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
|
||||
if _BACKEND_DIR not in sys.path:
|
||||
sys.path.insert(0, _BACKEND_DIR)
|
||||
|
||||
import utils.hf_token_validation as validation
|
||||
|
||||
|
||||
@pytest.fixture(autouse = True)
|
||||
def _reset_validation_state():
|
||||
validation.reset_hf_token_validation_state()
|
||||
yield
|
||||
validation.reset_hf_token_validation_state()
|
||||
|
||||
|
||||
def test_cached_token_does_not_spend_another_attempt(monkeypatch):
|
||||
calls = []
|
||||
|
||||
def _check(token):
|
||||
calls.append(token)
|
||||
return validation.TokenValidationResult(status = "valid")
|
||||
|
||||
monkeypatch.setattr(validation, "_check_remote", _check)
|
||||
first = validation.validate_hf_token("hf_valid", rate_key = "user:ip")
|
||||
second = validation.validate_hf_token("hf_valid", rate_key = "user:ip")
|
||||
|
||||
assert first.status == second.status == "valid"
|
||||
assert calls == ["hf_valid"]
|
||||
|
||||
|
||||
def test_three_uncached_attempts_per_hour(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
validation,
|
||||
"_check_remote",
|
||||
lambda _token: validation.TokenValidationResult(status = "invalid"),
|
||||
)
|
||||
|
||||
for index in range(3):
|
||||
result = validation.validate_hf_token(f"hf_bad_{index}", rate_key = "user:ip")
|
||||
assert result.status == "invalid"
|
||||
|
||||
limited = validation.validate_hf_token("hf_bad_4", rate_key = "user:ip")
|
||||
assert limited.status == "rate_limited"
|
||||
assert limited.retry_after_seconds is not None
|
||||
assert limited.retry_after_seconds > 0
|
||||
|
||||
other_user = validation.validate_hf_token("hf_other", rate_key = "other:ip")
|
||||
assert other_user.status == "invalid"
|
||||
|
||||
|
||||
def test_window_rolls_forward(monkeypatch):
|
||||
clock = {"now": 100.0}
|
||||
monkeypatch.setattr(validation.time, "monotonic", lambda: clock["now"])
|
||||
monkeypatch.setattr(validation, "_MAX_ATTEMPTS", 1)
|
||||
monkeypatch.setattr(validation, "_WINDOW_SECONDS", 10.0)
|
||||
monkeypatch.setattr(
|
||||
validation,
|
||||
"_check_remote",
|
||||
lambda _token: validation.TokenValidationResult(status = "invalid"),
|
||||
)
|
||||
|
||||
assert validation.validate_hf_token("hf_a", rate_key = "user:ip").status == "invalid"
|
||||
assert validation.validate_hf_token("hf_b", rate_key = "user:ip").status == "rate_limited"
|
||||
clock["now"] += 11.0
|
||||
assert validation.validate_hf_token("hf_b", rate_key = "user:ip").status == "invalid"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("status_code", "expected"),
|
||||
[(200, "valid"), (401, "invalid"), (429, "rate_limited"), (500, "unavailable")],
|
||||
)
|
||||
def test_remote_status_classification(monkeypatch, status_code, expected):
|
||||
response = httpx.Response(
|
||||
status_code,
|
||||
request = httpx.Request("GET", "https://huggingface.co/api/whoami-v2"),
|
||||
headers = {"Retry-After": "42"} if status_code == 429 else None,
|
||||
)
|
||||
|
||||
class _Session:
|
||||
def get(self, url, *, headers, timeout):
|
||||
assert url == "https://huggingface.co/api/whoami-v2"
|
||||
assert headers["authorization"] == "Bearer hf_test"
|
||||
assert timeout == validation._REMOTE_TIMEOUT_SECONDS
|
||||
return response
|
||||
|
||||
monkeypatch.setattr(validation, "get_session", lambda: _Session())
|
||||
result = validation._check_remote("hf_test")
|
||||
assert result.status == expected
|
||||
if status_code == 429:
|
||||
assert result.retry_after_seconds == 42
|
||||
|
||||
|
||||
def test_wrapped_http_401_is_invalid(monkeypatch):
|
||||
response = httpx.Response(
|
||||
401,
|
||||
request = httpx.Request("GET", "https://huggingface.co/api/whoami-v2"),
|
||||
)
|
||||
|
||||
class _Session:
|
||||
def get(self, _url, **_kwargs):
|
||||
error = RuntimeError("Invalid user token.")
|
||||
error.response = response
|
||||
raise error
|
||||
|
||||
monkeypatch.setattr(validation, "get_session", lambda: _Session())
|
||||
assert validation._check_remote("hf_test").status == "invalid"
|
||||
|
||||
|
||||
def test_remote_timeout_is_bounded_and_unavailable(monkeypatch):
|
||||
class _Session:
|
||||
def get(self, _url, *, headers, timeout):
|
||||
assert headers["authorization"] == "Bearer hf_test"
|
||||
assert timeout == validation._REMOTE_TIMEOUT_SECONDS
|
||||
raise TimeoutError("timed out")
|
||||
|
||||
monkeypatch.setattr(validation, "get_session", lambda: _Session())
|
||||
assert validation._check_remote("hf_test").status == "unavailable"
|
||||
|
||||
|
||||
def test_raw_token_is_not_retained(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
validation,
|
||||
"_check_remote",
|
||||
lambda _token: validation.TokenValidationResult(status = "valid"),
|
||||
)
|
||||
token = "hf_do_not_store_this_value"
|
||||
validation.validate_hf_token(token, rate_key = "user:ip")
|
||||
|
||||
assert token not in repr(validation._cache)
|
||||
assert token not in repr(validation._attempts)
|
||||
|
||||
|
||||
def test_unexpected_remote_exception_releases_singleflight(monkeypatch):
|
||||
calls = 0
|
||||
monkeypatch.setattr(validation, "_INFLIGHT_WAIT_SECONDS", 0.0)
|
||||
|
||||
def _check(_token):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 1:
|
||||
raise RuntimeError("unexpected failure")
|
||||
return validation.TokenValidationResult(status = "valid")
|
||||
|
||||
monkeypatch.setattr(validation, "_check_remote", _check)
|
||||
|
||||
with pytest.raises(RuntimeError, match = "unexpected failure"):
|
||||
validation.validate_hf_token("hf_test", rate_key = "user:ip")
|
||||
|
||||
result = validation.validate_hf_token("hf_test", rate_key = "user:ip")
|
||||
assert result.status == "valid"
|
||||
assert calls == 2
|
||||
assert validation._inflight == {}
|
||||
|
|
@ -38,7 +38,7 @@ from utils.hardware import (
|
|||
DeviceType,
|
||||
)
|
||||
import utils.hardware.hardware as _hw_module
|
||||
from utils.utils import format_error_message
|
||||
from utils.utils import format_error_message, is_hf_authentication_error
|
||||
|
||||
|
||||
# ========== Helpers ==========
|
||||
|
|
@ -439,6 +439,20 @@ class TestFormatErrorMessage:
|
|||
msg = format_error_message(err, "any/model")
|
||||
assert "invalid" in msg.lower()
|
||||
|
||||
def test_hf_authentication_error_follows_wrapped_401(self):
|
||||
response = type("Response", (), {"status_code": 401})()
|
||||
auth_error = Exception("request failed")
|
||||
auth_error.response = response
|
||||
wrapper = RuntimeError("model validation failed")
|
||||
wrapper.__cause__ = auth_error
|
||||
assert is_hf_authentication_error(wrapper) is True
|
||||
|
||||
def test_hf_authentication_error_does_not_treat_429_as_invalid(self):
|
||||
response = type("Response", (), {"status_code": 429})()
|
||||
rate_error = Exception("too many requests")
|
||||
rate_error.response = response
|
||||
assert is_hf_authentication_error(rate_error) is False
|
||||
|
||||
# --- OOM on CUDA ---
|
||||
|
||||
@needs_torch
|
||||
|
|
|
|||
208
studio/backend/utils/hf_token_validation.py
Normal file
208
studio/backend/utils/hf_token_validation.py
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Cached, rate-limited Hugging Face token validation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
from huggingface_hub import HfApi
|
||||
from huggingface_hub.utils import build_hf_headers, get_session
|
||||
|
||||
|
||||
TokenValidationStatus = Literal["valid", "invalid", "rate_limited", "unavailable"]
|
||||
|
||||
|
||||
@dataclass(frozen = True)
|
||||
class TokenValidationResult:
|
||||
status: TokenValidationStatus
|
||||
retry_after_seconds: int | None = None
|
||||
|
||||
|
||||
_WINDOW_SECONDS = 3600.0
|
||||
_MAX_ATTEMPTS = 3
|
||||
_CACHE_TTL_SECONDS = 3600.0
|
||||
_TEMPORARY_CACHE_TTL_SECONDS = 15.0
|
||||
_MAX_BUCKETS = 4096
|
||||
_MAX_CACHE_ENTRIES = 4096
|
||||
_INFLIGHT_WAIT_SECONDS = 30.0
|
||||
_REMOTE_TIMEOUT_SECONDS = 10.0
|
||||
|
||||
_attempts: dict[str, deque[float]] = {}
|
||||
_cache: dict[str, tuple[float, TokenValidationResult]] = {}
|
||||
_inflight: dict[str, threading.Event] = {}
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def _fingerprint(token: str) -> str:
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _prune_attempts(bucket: deque[float], now: float) -> None:
|
||||
while bucket and now - bucket[0] >= _WINDOW_SECONDS:
|
||||
bucket.popleft()
|
||||
|
||||
|
||||
def _prune_locked(now: float) -> None:
|
||||
for key in list(_attempts):
|
||||
bucket = _attempts[key]
|
||||
_prune_attempts(bucket, now)
|
||||
if not bucket:
|
||||
del _attempts[key]
|
||||
for key, (expires_at, _result) in list(_cache.items()):
|
||||
if expires_at <= now:
|
||||
del _cache[key]
|
||||
|
||||
|
||||
def _cached_locked(fingerprint: str, now: float) -> TokenValidationResult | None:
|
||||
cached = _cache.get(fingerprint)
|
||||
if cached is None:
|
||||
return None
|
||||
expires_at, result = cached
|
||||
if expires_at <= now:
|
||||
del _cache[fingerprint]
|
||||
return None
|
||||
return result
|
||||
|
||||
|
||||
def _retry_after(bucket: deque[float], now: float) -> int:
|
||||
return max(1, int(_WINDOW_SECONDS - (now - bucket[0])) + 1)
|
||||
|
||||
|
||||
def _reserve_attempt_locked(rate_key: str, now: float) -> TokenValidationResult | None:
|
||||
bucket = _attempts.get(rate_key)
|
||||
if bucket is None:
|
||||
if len(_attempts) >= _MAX_BUCKETS:
|
||||
_prune_locked(now)
|
||||
if len(_attempts) >= _MAX_BUCKETS:
|
||||
return TokenValidationResult(
|
||||
status = "rate_limited",
|
||||
retry_after_seconds = max(1, int(_WINDOW_SECONDS)),
|
||||
)
|
||||
bucket = _attempts[rate_key] = deque()
|
||||
_prune_attempts(bucket, now)
|
||||
if len(bucket) >= _MAX_ATTEMPTS:
|
||||
return TokenValidationResult(
|
||||
status = "rate_limited",
|
||||
retry_after_seconds = _retry_after(bucket, now),
|
||||
)
|
||||
bucket.append(now)
|
||||
return None
|
||||
|
||||
|
||||
def _http_status(response: object | None) -> int | None:
|
||||
status = getattr(response, "status_code", None)
|
||||
try:
|
||||
return int(status) if status is not None else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _remote_retry_after(response: object | None) -> int | None:
|
||||
headers = getattr(response, "headers", None)
|
||||
if not headers:
|
||||
return None
|
||||
raw = headers.get("Retry-After")
|
||||
try:
|
||||
return max(1, int(float(raw))) if raw is not None else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _classify_response(response: object | None) -> TokenValidationResult:
|
||||
status = _http_status(response)
|
||||
if status is not None and 200 <= status < 300:
|
||||
return TokenValidationResult(status = "valid")
|
||||
if status == 401:
|
||||
return TokenValidationResult(status = "invalid")
|
||||
if status == 429:
|
||||
return TokenValidationResult(
|
||||
status = "rate_limited",
|
||||
retry_after_seconds = _remote_retry_after(response),
|
||||
)
|
||||
return TokenValidationResult(status = "unavailable")
|
||||
|
||||
|
||||
def _check_remote(token: str) -> TokenValidationResult:
|
||||
api = HfApi()
|
||||
try:
|
||||
# HfApi.whoami has no timeout parameter in the pinned Hub client.
|
||||
# Use its session and headers against the same whoami endpoint.
|
||||
response = get_session().get(
|
||||
f"{api.endpoint}/api/whoami-v2",
|
||||
headers = build_hf_headers(token = token),
|
||||
timeout = _REMOTE_TIMEOUT_SECONDS,
|
||||
)
|
||||
except Exception as exc:
|
||||
# huggingface-hub 0.36.x can wrap a 401 as requests.HTTPError.
|
||||
return _classify_response(getattr(exc, "response", None))
|
||||
return _classify_response(response)
|
||||
|
||||
|
||||
def validate_hf_token(token: str, *, rate_key: str) -> TokenValidationResult:
|
||||
"""Validate ``token`` without retaining it, sharing results across callers.
|
||||
|
||||
Cached checks do not consume the caller's three-per-hour network budget. A
|
||||
single-flight event also prevents simultaneously mounted UI surfaces from
|
||||
sending duplicate ``whoami`` requests for the same token.
|
||||
"""
|
||||
normalized = token.strip()
|
||||
if not normalized:
|
||||
return TokenValidationResult(status = "invalid")
|
||||
token_fingerprint = _fingerprint(normalized)
|
||||
owner_event: threading.Event | None = None
|
||||
|
||||
try:
|
||||
while True:
|
||||
now = time.monotonic()
|
||||
with _lock:
|
||||
cached = _cached_locked(token_fingerprint, now)
|
||||
if cached is not None:
|
||||
return cached
|
||||
waiting = _inflight.get(token_fingerprint)
|
||||
if waiting is None:
|
||||
limited = _reserve_attempt_locked(rate_key, now)
|
||||
if limited is not None:
|
||||
return limited
|
||||
owner_event = threading.Event()
|
||||
_inflight[token_fingerprint] = owner_event
|
||||
break
|
||||
if not waiting.wait(_INFLIGHT_WAIT_SECONDS):
|
||||
return TokenValidationResult(status = "unavailable")
|
||||
|
||||
result = _check_remote(normalized)
|
||||
now = time.monotonic()
|
||||
ttl = (
|
||||
_CACHE_TTL_SECONDS
|
||||
if result.status in ("valid", "invalid")
|
||||
else max(_TEMPORARY_CACHE_TTL_SECONDS, float(result.retry_after_seconds or 0))
|
||||
)
|
||||
with _lock:
|
||||
if len(_cache) >= _MAX_CACHE_ENTRIES:
|
||||
_prune_locked(now)
|
||||
if len(_cache) < _MAX_CACHE_ENTRIES:
|
||||
_cache[token_fingerprint] = (now + ttl, result)
|
||||
return result
|
||||
finally:
|
||||
if owner_event is not None:
|
||||
with _lock:
|
||||
event = _inflight.get(token_fingerprint)
|
||||
if event is owner_event:
|
||||
_inflight.pop(token_fingerprint, None)
|
||||
event.set()
|
||||
|
||||
|
||||
def reset_hf_token_validation_state() -> None:
|
||||
"""Clear process state for test isolation."""
|
||||
with _lock:
|
||||
for event in _inflight.values():
|
||||
event.set()
|
||||
_inflight.clear()
|
||||
_attempts.clear()
|
||||
_cache.clear()
|
||||
|
|
@ -123,6 +123,26 @@ def without_hf_auth():
|
|||
os.environ.pop("HF_HUB_DISABLE_IMPLICIT_TOKEN", None)
|
||||
|
||||
|
||||
def is_hf_authentication_error(error: Exception) -> bool:
|
||||
"""Return whether an exception chain contains a definitive HF auth failure."""
|
||||
seen: set[int] = set()
|
||||
current: BaseException | None = error
|
||||
while current is not None and id(current) not in seen:
|
||||
seen.add(id(current))
|
||||
response = getattr(current, "response", None)
|
||||
status = getattr(response, "status_code", None)
|
||||
try:
|
||||
if status is not None and int(status) == 401:
|
||||
return True
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
message = str(current).lower()
|
||||
if "invalid user token" in message or "invalid hf token" in message:
|
||||
return True
|
||||
current = current.__cause__ or current.__context__
|
||||
return False
|
||||
|
||||
|
||||
def format_error_message(error: Exception, model_name: str) -> str:
|
||||
"""
|
||||
Format a user-friendly error message for common load issues.
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
type ChatSearch,
|
||||
} from "@/features/chat";
|
||||
import { RemoteCodeConsentDialog } from "@/features/security";
|
||||
import { HfTokenWarningDialog } from "@/features/hf-auth";
|
||||
import { TransformersUpgradeDialog } from "@/features/transformers-upgrade";
|
||||
import { useTrainingUnloadGuard } from "@/features/training";
|
||||
import { useExportRuntimeLifecycle } from "@/features/export";
|
||||
|
|
@ -230,6 +231,7 @@ function RootLayout() {
|
|||
<AppProvider>
|
||||
<PersonalizationSyncMount />
|
||||
{!isAuthFlowRoute && <SettingsDialog />}
|
||||
<HfTokenWarningDialog />
|
||||
<RemoteCodeConsentDialog />
|
||||
<TransformersUpgradeDialog />
|
||||
{hideNavbar ? (
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { authFetch } from "@/features/auth";
|
||||
import { prepareHfTokenForUse } from "@/features/hf-auth";
|
||||
// These helpers are deliberately API-layer-only and are not part of their
|
||||
// features' React-facing public barrels.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
|
|
@ -108,11 +109,14 @@ export async function getApiMonitorEntry(id: string): Promise<ApiMonitorEntry> {
|
|||
export async function loadModel(
|
||||
payload: LoadModelRequest,
|
||||
): Promise<LoadModelResponse> {
|
||||
const preparedToken = await prepareHfTokenForUse(payload.hf_token);
|
||||
if (!preparedToken.proceed) throw new Error("Model load cancelled.");
|
||||
const response = await authFetch("/api/inference/load", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
...payload,
|
||||
hf_token: preparedToken.token,
|
||||
native_path_lease: payload.nativePathLease ?? null,
|
||||
nativePathLease: undefined,
|
||||
}),
|
||||
|
|
@ -123,13 +127,15 @@ export async function loadModel(
|
|||
export async function validateModel(
|
||||
payload: LoadModelRequest,
|
||||
): Promise<ValidateModelResponse> {
|
||||
const preparedToken = await prepareHfTokenForUse(payload.hf_token);
|
||||
if (!preparedToken.proceed) throw new Error("Model load cancelled.");
|
||||
const response = await authFetch("/api/inference/validate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model_path: payload.model_path,
|
||||
native_path_lease: payload.nativePathLease ?? null,
|
||||
hf_token: payload.hf_token,
|
||||
hf_token: preparedToken.token,
|
||||
gguf_variant: payload.gguf_variant ?? null,
|
||||
// Intended load settings so validate's preflight matches the follow-up
|
||||
// /load. Default placement is sized against the selected GPUs.
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ import {
|
|||
import { usePlatformStore } from "@/config/env";
|
||||
import { useHubModelSearch } from "@/features/hub/hooks/use-hub-model-search";
|
||||
import { confirmRemoteCodeIfNeeded } from "@/features/security";
|
||||
import { prepareHfTokenForUse } from "@/features/hf-auth";
|
||||
import { GuidedTour, useGuidedTourController } from "@/features/tour";
|
||||
import {
|
||||
type LocalModelInfo,
|
||||
|
|
@ -724,11 +725,17 @@ export function ExportPage() {
|
|||
const checkpointPath = selectedCp?.path ?? null;
|
||||
|
||||
const pushToHub = destination === "hub";
|
||||
const preparedToken = await prepareHfTokenForUse(hfToken, {
|
||||
allowAnonymous: !pushToHub,
|
||||
});
|
||||
if (!preparedToken.proceed) return;
|
||||
const actionHfToken = preparedToken.token ?? "";
|
||||
|
||||
const repoId =
|
||||
pushToHub && hfUsername && modelName
|
||||
? `${hfUsername}/${modelName}`
|
||||
: undefined;
|
||||
const token = pushToHub && hfToken ? hfToken : undefined;
|
||||
const token = pushToHub && actionHfToken ? actionHfToken : undefined;
|
||||
// The GGUF method with the LoRA target reuses the LoRA-adapter export path.
|
||||
const effectiveMethod: ExportMethod = ggufAsLora ? "lora" : exportMethod;
|
||||
const emitLoraGguf =
|
||||
|
|
@ -747,7 +754,7 @@ export function ExportPage() {
|
|||
if (sourceMode !== "checkpoint") {
|
||||
const remoteCodeOk = await confirmRemoteCodeIfNeeded({
|
||||
modelName: source,
|
||||
hfToken: hfToken || null,
|
||||
hfToken: actionHfToken || null,
|
||||
// An HF source can need trust_remote_code via its YAML default with no
|
||||
// auto_map to review; signal it so a YAML-only model does not export
|
||||
// with it false.
|
||||
|
|
@ -767,7 +774,7 @@ export function ExportPage() {
|
|||
modelSource,
|
||||
trustRemoteCode,
|
||||
approvedRemoteCodeFingerprint,
|
||||
loadToken: hfToken || null,
|
||||
loadToken: actionHfToken || null,
|
||||
exportMethod: effectiveMethod,
|
||||
isAdapter: adapterExport,
|
||||
quantLevels,
|
||||
|
|
|
|||
44
studio/frontend/src/features/hf-auth/api.ts
Normal file
44
studio/frontend/src/features/hf-auth/api.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
// 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 { authFetch } from "@/features/auth";
|
||||
// This header helper is API-layer-only and is not part of the feature's
|
||||
// React-facing public barrel.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { hubTokenHeader } from "@/features/hub/lib/hub-token-header";
|
||||
|
||||
export type HfTokenValidationStatus =
|
||||
| "missing"
|
||||
| "valid"
|
||||
| "invalid"
|
||||
| "rate_limited"
|
||||
| "unavailable";
|
||||
|
||||
export interface HfTokenValidationResult {
|
||||
status: HfTokenValidationStatus;
|
||||
retryAfterSeconds: number | null;
|
||||
}
|
||||
|
||||
export async function validateHfToken(
|
||||
token: string | null | undefined,
|
||||
): Promise<HfTokenValidationResult> {
|
||||
const normalized = token?.trim() ?? "";
|
||||
if (!normalized) {
|
||||
return { status: "missing", retryAfterSeconds: null };
|
||||
}
|
||||
const response = await authFetch("/api/hub/token/validate", {
|
||||
method: "POST",
|
||||
headers: hubTokenHeader(normalized),
|
||||
});
|
||||
if (!response.ok) {
|
||||
return { status: "unavailable", retryAfterSeconds: null };
|
||||
}
|
||||
const body = (await response.json()) as {
|
||||
status?: HfTokenValidationStatus;
|
||||
retry_after_seconds?: number | null;
|
||||
};
|
||||
return {
|
||||
status: body.status ?? "unavailable",
|
||||
retryAfterSeconds: body.retry_after_seconds ?? null,
|
||||
};
|
||||
}
|
||||
63
studio/frontend/src/features/hf-auth/confirm-token.ts
Normal file
63
studio/frontend/src/features/hf-auth/confirm-token.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
// These stores are used outside React and are not part of their features'
|
||||
// React-facing public barrels.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useHfTokenStore } from "@/features/hub/stores/hf-token-store";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSettingsDialogStore } from "@/features/settings/stores/settings-dialog-store";
|
||||
import { validateHfToken } from "./api";
|
||||
import { useHfTokenWarningStore } from "./store";
|
||||
|
||||
export interface PreparedHfToken {
|
||||
proceed: boolean;
|
||||
token: string | null;
|
||||
}
|
||||
|
||||
interface PrepareHfTokenOptions {
|
||||
allowAnonymous?: boolean;
|
||||
}
|
||||
|
||||
// A caller can retain the pre-dialog payload while the shared store is cleared.
|
||||
// Remember that one-session choice so a follow-up /load does not prompt again
|
||||
// after its preceding /validate already continued anonymously.
|
||||
const anonymousForSession = new Set<string>();
|
||||
|
||||
export async function prepareHfTokenForUse(
|
||||
token: string | null | undefined,
|
||||
options: PrepareHfTokenOptions = {},
|
||||
): Promise<PreparedHfToken> {
|
||||
const normalized = token?.trim() ?? "";
|
||||
if (!normalized) return { proceed: true, token: null };
|
||||
const allowAnonymous = options.allowAnonymous ?? true;
|
||||
if (allowAnonymous && anonymousForSession.has(normalized)) {
|
||||
return { proceed: true, token: null };
|
||||
}
|
||||
|
||||
let validation;
|
||||
try {
|
||||
validation = await validateHfToken(normalized);
|
||||
} catch {
|
||||
// Validation is advisory. Let the real operation retain its own error.
|
||||
return { proceed: true, token: normalized };
|
||||
}
|
||||
if (validation.status !== "invalid") {
|
||||
// A connectivity failure or rate limit cannot prove that a token is bad.
|
||||
// Let the real operation proceed and retain its repository-specific error.
|
||||
return { proceed: true, token: normalized };
|
||||
}
|
||||
|
||||
const decision = await useHfTokenWarningStore
|
||||
.getState()
|
||||
.requestDecision(allowAnonymous);
|
||||
if (decision === "anonymous") {
|
||||
anonymousForSession.add(normalized);
|
||||
useHfTokenStore.getState().clearToken();
|
||||
return { proceed: true, token: null };
|
||||
}
|
||||
if (decision === "replace") {
|
||||
useSettingsDialogStore.getState().openDialog("general");
|
||||
}
|
||||
return { proceed: false, token: normalized };
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
// 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 {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { useHfTokenWarningStore } from "./store";
|
||||
|
||||
export function HfTokenWarningDialog() {
|
||||
const open = useHfTokenWarningStore((state) => state.open);
|
||||
const allowAnonymous = useHfTokenWarningStore(
|
||||
(state) => state.allowAnonymous,
|
||||
);
|
||||
const resolve = useHfTokenWarningStore((state) => state.resolve);
|
||||
|
||||
return (
|
||||
<AlertDialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) resolve("cancel");
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent className="max-w-md">
|
||||
<AlertDialogHeader>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-full bg-amber-500/10 text-amber-600 dark:text-amber-400">
|
||||
<AlertTriangle className="size-5" />
|
||||
</div>
|
||||
<div className="space-y-1 text-left">
|
||||
<AlertDialogTitle>Hugging Face token is invalid</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{allowAnonymous
|
||||
? "Hugging Face rejected the saved token. Replace it to access private or gated repositories, or continue without it for public and fully downloaded models."
|
||||
: "Hugging Face rejected the saved token. Replace it before uploading to the Hub."}
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter className="sm:justify-between">
|
||||
<AlertDialogCancel onClick={() => resolve("cancel")}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<div className="flex flex-col-reverse gap-2 sm:flex-row">
|
||||
{allowAnonymous ? (
|
||||
<Button variant="outline" onClick={() => resolve("anonymous")}>
|
||||
Continue without token
|
||||
</Button>
|
||||
) : null}
|
||||
<AlertDialogAction onClick={() => resolve("replace")}>
|
||||
Replace token
|
||||
</AlertDialogAction>
|
||||
</div>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
10
studio/frontend/src/features/hf-auth/index.ts
Normal file
10
studio/frontend/src/features/hf-auth/index.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
// 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 { validateHfToken } from "./api";
|
||||
export type {
|
||||
HfTokenValidationResult,
|
||||
HfTokenValidationStatus,
|
||||
} from "./api";
|
||||
export { prepareHfTokenForUse } from "./confirm-token";
|
||||
export { HfTokenWarningDialog } from "./hf-token-warning-dialog";
|
||||
33
studio/frontend/src/features/hf-auth/store.ts
Normal file
33
studio/frontend/src/features/hf-auth/store.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
// 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 { create } from "zustand";
|
||||
|
||||
export type HfTokenWarningDecision = "anonymous" | "replace" | "cancel";
|
||||
type Resolver = (decision: HfTokenWarningDecision) => void;
|
||||
|
||||
let pendingResolver: Resolver | null = null;
|
||||
|
||||
interface HfTokenWarningStore {
|
||||
open: boolean;
|
||||
allowAnonymous: boolean;
|
||||
requestDecision: (allowAnonymous: boolean) => Promise<HfTokenWarningDecision>;
|
||||
resolve: (decision: HfTokenWarningDecision) => void;
|
||||
}
|
||||
|
||||
export const useHfTokenWarningStore = create<HfTokenWarningStore>((set) => ({
|
||||
open: false,
|
||||
allowAnonymous: true,
|
||||
requestDecision: (allowAnonymous) =>
|
||||
new Promise<HfTokenWarningDecision>((resolve) => {
|
||||
pendingResolver?.("cancel");
|
||||
pendingResolver = resolve;
|
||||
set({ open: true, allowAnonymous });
|
||||
}),
|
||||
resolve: (decision) => {
|
||||
const resolver = pendingResolver;
|
||||
pendingResolver = null;
|
||||
set({ open: false, allowAnonymous: true });
|
||||
resolver?.(decision);
|
||||
},
|
||||
}));
|
||||
|
|
@ -21,6 +21,7 @@ import {
|
|||
setShowLlamaUpdateBanner,
|
||||
useShowLlamaUpdateBanner,
|
||||
} from "@/hooks/use-llama-update-pref";
|
||||
import { useHfTokenValidation } from "@/hooks";
|
||||
import { LOCALE_STORAGE_KEY, useT } from "@/i18n";
|
||||
import { isTauri } from "@/lib/api-base";
|
||||
import { copyToClipboard } from "@/lib/copy-to-clipboard";
|
||||
|
|
@ -216,11 +217,18 @@ export function GeneralTab() {
|
|||
if (trimmed !== hfToken) setHfToken(trimmed);
|
||||
};
|
||||
|
||||
const clearHfToken = () => {
|
||||
draftRef.current = "";
|
||||
setDraftToken("");
|
||||
setHfToken("");
|
||||
};
|
||||
|
||||
// Show an "accepted" tick once a non-empty token has been committed to the
|
||||
// store and the field still matches it (i.e. not mid-edit). Gives the user
|
||||
// feedback that a pasted token was saved.
|
||||
const tokenSaved =
|
||||
draftToken.trim().length > 0 && draftToken.trim() === (hfToken ?? "");
|
||||
const tokenValidation = useHfTokenValidation(hfToken ?? "");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
|
@ -498,46 +506,70 @@ export function GeneralTab() {
|
|||
label={t("settings.general.huggingFaceToken")}
|
||||
description={t("settings.general.huggingFaceTokenDescription")}
|
||||
>
|
||||
<div className="relative w-[260px]">
|
||||
<Input
|
||||
type={showToken ? "text" : "password"}
|
||||
placeholder="hf_…"
|
||||
value={draftToken}
|
||||
onChange={(e) => setDraftToken(e.target.value)}
|
||||
onBlur={commitToken}
|
||||
className={cn(
|
||||
"h-8 w-full font-mono text-xs",
|
||||
tokenSaved ? "pr-14" : "pr-8",
|
||||
)}
|
||||
/>
|
||||
{tokenSaved ? (
|
||||
// Decorative: pointer-events-none lets clicks reach the input
|
||||
// underneath so the field still focuses anywhere.
|
||||
<span
|
||||
className="pointer-events-none absolute right-7 top-1/2 flex size-5 -translate-y-1/2 items-center justify-center text-emerald-600 duration-150 animate-in fade-in zoom-in dark:text-emerald-500"
|
||||
role="img"
|
||||
aria-label={t("settings.general.tokenSaved")}
|
||||
<div className="flex flex-col items-end gap-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative w-[260px]">
|
||||
<Input
|
||||
type={showToken ? "text" : "password"}
|
||||
name="hf-token"
|
||||
autoComplete="new-password"
|
||||
spellCheck={false}
|
||||
placeholder="hf_…"
|
||||
value={draftToken}
|
||||
onChange={(e) => setDraftToken(e.target.value)}
|
||||
onBlur={commitToken}
|
||||
className={cn(
|
||||
"h-8 w-full font-mono text-xs",
|
||||
tokenSaved ? "pr-14" : "pr-8",
|
||||
)}
|
||||
/>
|
||||
{tokenSaved ? (
|
||||
// Decorative: pointer-events-none lets clicks reach the input
|
||||
// underneath so the field still focuses anywhere.
|
||||
<span
|
||||
className="pointer-events-none absolute right-7 top-1/2 flex size-5 -translate-y-1/2 items-center justify-center text-emerald-600 duration-150 animate-in fade-in zoom-in dark:text-emerald-500"
|
||||
role="img"
|
||||
aria-label={t("settings.general.tokenSaved")}
|
||||
>
|
||||
<Check className="size-4" strokeWidth={2.5} />
|
||||
</span>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowToken((s) => !s)}
|
||||
className="absolute right-1.5 top-1/2 flex size-5 -translate-y-1/2 items-center justify-center rounded text-muted-foreground transition-colors hover:text-foreground"
|
||||
aria-label={
|
||||
showToken
|
||||
? t("settings.general.hideToken")
|
||||
: t("settings.general.showToken")
|
||||
}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showToken ? (
|
||||
<EyeOff className="size-3.5" />
|
||||
) : (
|
||||
<Eye className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
disabled={!draftToken && !hfToken}
|
||||
onClick={clearHfToken}
|
||||
>
|
||||
<Check className="size-4" strokeWidth={2.5} />
|
||||
</span>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
{tokenValidation.isChecking ? (
|
||||
<p className="text-xs text-muted-foreground">Checking token…</p>
|
||||
) : tokenValidation.error ? (
|
||||
<p className="max-w-[330px] text-right text-xs text-destructive">
|
||||
{tokenValidation.error}
|
||||
</p>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowToken((s) => !s)}
|
||||
className="absolute right-1.5 top-1/2 flex size-5 -translate-y-1/2 items-center justify-center rounded text-muted-foreground transition-colors hover:text-foreground"
|
||||
aria-label={
|
||||
showToken
|
||||
? t("settings.general.hideToken")
|
||||
: t("settings.general.showToken")
|
||||
}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showToken ? (
|
||||
<EyeOff className="size-3.5" />
|
||||
) : (
|
||||
<Eye className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
{/* The desktop app authenticates via desktop auto-auth with a generated
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { authFetch } from "@/features/auth";
|
||||
import { prepareHfTokenForUse } from "@/features/hf-auth";
|
||||
import { readFastApiError } from "@/lib/format-fastapi-error";
|
||||
import type {
|
||||
TrainingStartRequest,
|
||||
|
|
@ -30,10 +31,12 @@ async function parseJson<T>(response: Response): Promise<T> {
|
|||
export async function startTraining(
|
||||
payload: TrainingStartRequest,
|
||||
): Promise<TrainingStartResponse> {
|
||||
const preparedToken = await prepareHfTokenForUse(payload.hf_token);
|
||||
if (!preparedToken.proceed) throw new Error("Training start cancelled.");
|
||||
const response = await authFetch("/api/train/start", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
body: JSON.stringify({ ...payload, hf_token: preparedToken.token }),
|
||||
});
|
||||
return parseJson<TrainingStartResponse>(response);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { primeNativeNotificationPermission } from "@/lib/native-notifications";
|
||||
import { prepareHfTokenForUse } from "@/features/hf-auth";
|
||||
import { confirmRemoteCodeIfNeeded } from "@/features/security";
|
||||
import { useCallback } from "react";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
|
@ -43,7 +44,7 @@ export function useTrainingActions() {
|
|||
const startError = useTrainingRuntimeStore((state) => state.startError);
|
||||
|
||||
const startTrainingRun = useCallback(async (): Promise<boolean> => {
|
||||
const config = useTrainingConfigStore.getState();
|
||||
let config = useTrainingConfigStore.getState();
|
||||
const runtimeStore = useTrainingRuntimeStore.getState();
|
||||
const dialogStore = useDatasetPreviewDialogStore.getState();
|
||||
|
||||
|
|
@ -54,6 +55,13 @@ export function useTrainingActions() {
|
|||
return false;
|
||||
}
|
||||
|
||||
const preparedToken = await prepareHfTokenForUse(config.hfToken);
|
||||
if (!preparedToken.proceed) return false;
|
||||
if ((preparedToken.token ?? "") !== config.hfToken) {
|
||||
config.setHfToken(preparedToken.token ?? "");
|
||||
config = useTrainingConfigStore.getState();
|
||||
}
|
||||
|
||||
primeNativeNotificationPermission().catch(() => undefined);
|
||||
|
||||
runtimeStore.setStartResources(
|
||||
|
|
@ -226,6 +234,13 @@ export function useTrainingActions() {
|
|||
resume_from_checkpoint: outputDir,
|
||||
} as TrainingStartRequest;
|
||||
|
||||
const preparedToken = await prepareHfTokenForUse(payload.hf_token);
|
||||
if (!preparedToken.proceed) {
|
||||
runtimeStore.setStarting(false);
|
||||
return false;
|
||||
}
|
||||
payload.hf_token = preparedToken.token;
|
||||
|
||||
runtimeStore.setStartResources(
|
||||
payload.model_name,
|
||||
payload.hf_dataset,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
import { whoAmI } from "@huggingface/hub";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { validateHfToken } from "@/features/hf-auth";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useDebouncedValue } from "./use-debounced-value";
|
||||
|
||||
export interface HfTokenValidationState {
|
||||
|
|
@ -17,6 +17,20 @@ const INITIAL: HfTokenValidationState = {
|
|||
isChecking: false,
|
||||
};
|
||||
|
||||
interface CompletedValidation extends HfTokenValidationState {
|
||||
token: string;
|
||||
}
|
||||
|
||||
const NO_COMPLETED_VALIDATION: CompletedValidation = {
|
||||
...INITIAL,
|
||||
token: "",
|
||||
};
|
||||
|
||||
// Current user access tokens contain 34 characters after the hf_ prefix.
|
||||
// Action-time validation still accepts legacy shapes without spending quota
|
||||
// on every intermediate value typed into a live form field.
|
||||
const COMPLETE_HF_TOKEN = /^hf_[A-Za-z0-9]{34}$/;
|
||||
|
||||
/**
|
||||
* Validates the HF token via the whoami-v2 API, debounced to avoid excessive
|
||||
* requests while typing. isValid is null until checked.
|
||||
|
|
@ -26,39 +40,73 @@ export function useHfTokenValidation(token: string): HfTokenValidationState {
|
|||
token.trim().replace(/^["']+|["']+$/g, ""),
|
||||
500,
|
||||
);
|
||||
const [state, setState] = useState<HfTokenValidationState>(INITIAL);
|
||||
const [completed, setCompleted] = useState<CompletedValidation>(
|
||||
NO_COMPLETED_VALIDATION,
|
||||
);
|
||||
const versionRef = useRef(0);
|
||||
|
||||
const runCheck = useCallback(async (t: string) => {
|
||||
if (!t) {
|
||||
setState({ isValid: null, error: null, isChecking: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const v = ++versionRef.current;
|
||||
setState((prev) => ({ ...prev, isChecking: true, error: null }));
|
||||
|
||||
try {
|
||||
await whoAmI({ accessToken: t });
|
||||
if (versionRef.current !== v) return;
|
||||
setState({ isValid: true, error: null, isChecking: false });
|
||||
} catch {
|
||||
if (versionRef.current !== v) return;
|
||||
setState({
|
||||
isValid: false,
|
||||
error: "invalid or expired token",
|
||||
isChecking: false,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
const shouldValidate = COMPLETE_HF_TOKEN.test(debouncedToken);
|
||||
|
||||
useEffect(() => {
|
||||
if (!debouncedToken) {
|
||||
setState(INITIAL);
|
||||
if (!shouldValidate) {
|
||||
versionRef.current += 1;
|
||||
return;
|
||||
}
|
||||
runCheck(debouncedToken);
|
||||
}, [debouncedToken, runCheck]);
|
||||
const version = ++versionRef.current;
|
||||
void validateHfToken(debouncedToken).then(
|
||||
(result) => {
|
||||
if (versionRef.current !== version) return;
|
||||
if (result.status === "valid") {
|
||||
setCompleted({
|
||||
token: debouncedToken,
|
||||
isValid: true,
|
||||
error: null,
|
||||
isChecking: false,
|
||||
});
|
||||
} else if (result.status === "invalid") {
|
||||
setCompleted({
|
||||
token: debouncedToken,
|
||||
isValid: false,
|
||||
error: "invalid or expired token",
|
||||
isChecking: false,
|
||||
});
|
||||
} else if (result.status === "rate_limited") {
|
||||
const wait = result.retryAfterSeconds
|
||||
? ` Try again in about ${Math.ceil(result.retryAfterSeconds / 60)} minute(s).`
|
||||
: " Try again later.";
|
||||
setCompleted({
|
||||
token: debouncedToken,
|
||||
isValid: null,
|
||||
error: `Token verification is rate limited.${wait}`,
|
||||
isChecking: false,
|
||||
});
|
||||
} else {
|
||||
setCompleted({
|
||||
token: debouncedToken,
|
||||
isValid: null,
|
||||
error: "Could not verify the token. Check your connection and try again.",
|
||||
isChecking: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
() => {
|
||||
if (versionRef.current !== version) return;
|
||||
setCompleted({
|
||||
token: debouncedToken,
|
||||
isValid: null,
|
||||
error: "Could not verify the token. Check your connection and try again.",
|
||||
isChecking: false,
|
||||
});
|
||||
},
|
||||
);
|
||||
}, [debouncedToken, shouldValidate]);
|
||||
|
||||
return state;
|
||||
if (!shouldValidate) return INITIAL;
|
||||
if (completed.token !== debouncedToken) {
|
||||
return { isValid: null, error: null, isChecking: true };
|
||||
}
|
||||
return {
|
||||
isValid: completed.isValid,
|
||||
error: completed.error,
|
||||
isChecking: false,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue