Compare commits

...
Sign in to create a new pull request.

7 commits

Author SHA1 Message Date
danielhanchen
53836cc62e Never let legacy migration evict existing per-model configs
Protecting the just-migrated keys during budget enforcement meant legacy
entries could evict the user's existing current-schema configs to make room,
and could still deadlock when unevictable future-schema records were present.
Protect the existing entries instead and let only the just-migrated legacy
entries be evicted when over budget: importing old settings never discards a
newer config, and since existing entries already fit the budget it cannot
deadlock.
2026-07-17 23:18:52 +00:00
danielhanchen
1b3a47b626 Accept the HF token from the header or the legacy query param
The model config, vision-check, and embedding-check endpoints now read the
token from the X-Unsloth-HF-Token header (preferred) and fall back to the
hf_token query param, matching /gguf-variants. This keeps older clients that
still send the query param working after the backend updates, while new clients
send the header and never put the token in the URL.
2026-07-17 23:18:52 +00:00
danielhanchen
709077c83c Fast-path the chat-template size check on character count
Reject an oversized chat_template_override on its character count (a lower
bound on the UTF-8 byte length) before allocating the encoded bytes, so a
multi-megabyte string is turned away without the intermediate encode.
2026-07-17 13:44:07 +00:00
pre-commit-ci[bot]
4895a7c67a [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-07-17 13:39:52 +00:00
danielhanchen
bdc1629fea Bound the protected key set during legacy config migration
Protecting every just-migrated key during eviction meant a legacy store with
more than MAX_ENTRIES entries could never be brought under budget, so
enforceStorageBudget failed on every reload and the migration never completed.
Cap the protected set to MAX_ENTRIES so an oversized legacy store migrates a
bounded subset and marks itself complete. Also condense a few future-schema
guard comments.
2026-07-17 13:38:07 +00:00
danielhanchen
9259937b2e Cap chat_template_override size on the model load path
The validate endpoint rejects chat templates over 65,536 bytes, but a direct
LoadRequest caller could still submit an arbitrarily large template for llama
to parse. Enforce the same byte limit on LoadRequest.chat_template_override.
2026-07-17 13:38:07 +00:00
danielhanchen
ca7e0cb527 Send the HF token as a header, not a URL query param
The model config, vision-check, and embedding-check requests appended the
Hugging Face token as ?hf_token=..., which leaks it into server access logs,
proxy logs, and browser history for gated/private models. Send it via the
existing X-Unsloth-HF-Token header (hubTokenHeader) instead, and read it on the
backend through the standard get_hf_token dependency, matching the picker's
template routes. Also drop two catch-block comments that just restated the code.
2026-07-17 13:38:07 +00:00
4 changed files with 44 additions and 20 deletions

View file

@ -19,6 +19,10 @@ from pydantic import (
)
# Redefined from picker/schemas.py so this core schema needn't import the picker package.
MAX_CHAT_TEMPLATE_BYTES = 65_536
class LoadRequest(BaseModel):
"""Request to load a model for inference"""
@ -54,8 +58,16 @@ class LoadRequest(BaseModel):
@field_validator("chat_template_override")
@classmethod
def normalize_blank_chat_template_override(cls, value: Optional[str]) -> Optional[str]:
if value is not None and value.strip() == "":
if value is None:
return None
# Reject on character count first (a lower bound on the UTF-8 byte length)
# so an oversized template is rejected before allocating the encoded bytes.
if len(value) > MAX_CHAT_TEMPLATE_BYTES:
raise ValueError(f"Chat template exceeds the {MAX_CHAT_TEMPLATE_BYTES}-byte limit.")
if value.strip() == "":
return None
if len(value.encode("utf-8")) > MAX_CHAT_TEMPLATE_BYTES:
raise ValueError(f"Chat template exceeds the {MAX_CHAT_TEMPLATE_BYTES}-byte limit.")
return value
cache_type_kv: Optional[str] = Field(

View file

@ -1797,10 +1797,14 @@ def _get_model_size_bytes(model_name: str, hf_token: Optional[str] = None) -> Op
async def get_model_config(
model_name: str,
hf_token: Optional[str] = Query(None),
hf_token_header: Optional[str] = Depends(get_hf_token),
current_subject: str = Depends(get_current_subject),
):
"""Get configuration for a specific model (wraps load_model_defaults)."""
try:
# Prefer the header token; keep the query param as a fallback for older
# clients that predate the header (as /gguf-variants does).
hf_token = _normalize_hf_token(hf_token_header) or _normalize_hf_token(hf_token)
if not is_local_path(model_name):
resolved = resolve_cached_repo_id_case(model_name)
if resolved != model_name:
@ -2518,6 +2522,7 @@ async def get_lora_base_model(lora_path: str, current_subject: str = Depends(get
async def check_vision_model(
model_name: str,
hf_token: Optional[str] = Query(None),
hf_token_header: Optional[str] = Depends(get_hf_token),
current_subject: str = Depends(get_current_subject),
):
"""
@ -2526,6 +2531,7 @@ async def check_vision_model(
This endpoint wraps the backend is_vision_model function.
"""
try:
hf_token = _normalize_hf_token(hf_token_header) or _normalize_hf_token(hf_token)
logger.info(f"Checking if vision model: {model_name}")
# Authenticate so a gated/private VLM classifies correctly (else 404 -> non-vision).
is_vision = is_vision_model(model_name, hf_token = hf_token)
@ -2550,6 +2556,7 @@ async def check_vision_model(
async def check_embedding_model(
model_name: str,
hf_token: Optional[str] = Query(None),
hf_token_header: Optional[str] = Depends(get_hf_token),
current_subject: str = Depends(get_current_subject),
):
"""
@ -2558,6 +2565,7 @@ async def check_embedding_model(
This endpoint wraps the backend is_embedding_model function.
"""
try:
hf_token = _normalize_hf_token(hf_token_header) or _normalize_hf_token(hf_token)
logger.info(f"Checking if embedding model: {model_name}")
is_embedding = is_embedding_model(model_name, hf_token = hf_token)

View file

@ -139,8 +139,7 @@ function deleteOldestEvictableEntry(
protectedKeys?: ReadonlySet<string>,
): { key: string; value: StoredMap[string] } | null {
for (const key of Object.keys(map)) {
// Never evict a future-schema entry an older client cannot interpret,
// matching the save/delete guards.
// Never evict a future-schema entry an older client can't interpret.
if (
protectedKeys?.has(key) ||
storedConfigVersion(map[key]) > STORAGE_SCHEMA_VERSION
@ -263,6 +262,10 @@ function migrateLegacyLoadSettingsOnce(): void {
return;
}
const map = readMapRaw();
// Snapshot the user's existing entries before merging: legacy settings are
// imported only into spare budget and must never evict a config the user
// already saved in the current schema.
const existingKeys = new Set(Object.keys(map));
const migratedKeys = mergeLegacyEntries(
map,
legacy as Record<string, unknown>,
@ -271,11 +274,11 @@ function migrateLegacyLoadSettingsOnce(): void {
localStorage.setItem(LEGACY_MIGRATION_FLAG, "1");
return;
}
// Protect the just-migrated entries during eviction. If the budget cannot
// fit them (e.g. storage is full of future-schema records an older client
// cannot evict), leave the flag unset so migration retries once space frees
// up rather than marking it complete and dropping the migrated config.
if (!enforceStorageBudget(map, new Set(migratedKeys))) {
// Protect existing entries so only the just-migrated legacy entries are
// evicted when over budget: importing old settings never discards a newer
// config, and since existing entries already fit the budget this cannot
// deadlock. On failure the flag stays unset so migration retries.
if (!enforceStorageBudget(map, existingKeys)) {
return;
}
if (writeMap(map)) {
@ -489,8 +492,7 @@ function loadPerModelConfig(
if (!key) {
return null;
}
// Never apply a future-schema record an older client cannot interpret,
// matching the save/delete/evict guards.
// Never apply a future-schema record an older client can't interpret.
if (storedConfigVersion(map[key]) > STORAGE_SCHEMA_VERSION) {
return null;
}
@ -548,8 +550,7 @@ export function deletePerModelConfig(
ggufVariant?: string | null,
): boolean {
const map = readMap();
// Mirror savePerModelConfig: never let an older client destroy a
// future-schema entry it cannot interpret.
// Never let an older client destroy a future-schema entry it can't interpret.
if (hasFutureConfigForModelVariant(map, modelId, ggufVariant)) {
return false;
}

View file

@ -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 { hubTokenHeader } from "@/features/hub";
interface VisionCheckResponse {
model_name: string;
@ -98,10 +99,10 @@ export async function checkVisionModel(
hfToken?: string | null,
): Promise<boolean> {
const encoded = encodeURIComponent(modelName);
const query = hfToken?.trim() ? `?hf_token=${encodeURIComponent(hfToken.trim())}` : "";
const response = await authFetch(`/api/models/check-vision/${encoded}${query}`);
const response = await authFetch(`/api/models/check-vision/${encoded}`, {
headers: hubTokenHeader(hfToken?.trim() || null),
});
if (!response.ok) {
// If the check fails (e.g. network error), default to non-vision
return false;
}
const data = (await response.json()) as VisionCheckResponse;
@ -114,10 +115,10 @@ export async function checkEmbeddingModel(
hfToken?: string | null,
): Promise<boolean> {
const encoded = encodeURIComponent(modelName);
const query = hfToken?.trim() ? `?hf_token=${encodeURIComponent(hfToken.trim())}` : "";
const response = await authFetch(`/api/models/check-embedding/${encoded}${query}`);
const response = await authFetch(`/api/models/check-embedding/${encoded}`, {
headers: hubTokenHeader(hfToken?.trim() || null),
});
if (!response.ok) {
// If the check fails (e.g. network error), default to non-embedding
return false;
}
const data = (await response.json()) as EmbeddingCheckResponse;
@ -130,8 +131,10 @@ export async function getModelConfig(
hfToken?: string,
): Promise<ModelConfigResponse> {
const encoded = encodeURIComponent(modelName);
const params = hfToken ? `?hf_token=${encodeURIComponent(hfToken)}` : "";
const response = await authFetch(`/api/models/config/${encoded}${params}`, { signal });
const response = await authFetch(`/api/models/config/${encoded}`, {
headers: hubTokenHeader(hfToken),
signal,
});
if (!response.ok) {
throw new Error(`Failed to fetch model config (${response.status})`);
}